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

Merge branch 'master' into feat/maintenance-mode-base

Taichi Masuyama 4 лет назад
Родитель
Сommit
7f2ef362c5
57 измененных файлов с 1289 добавлено и 457 удалено
  1. 1 0
      packages/app/config/logger/config.dev.js
  2. 4 3
      packages/app/package.json
  3. 1 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  4. 14 7
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  5. 1 1
      packages/app/src/components/Navbar/AuthorInfo.jsx
  6. 1 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  7. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  8. 1 1
      packages/app/src/components/SearchPage.tsx
  9. 1 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  10. 13 18
      packages/app/src/components/Sidebar.tsx
  11. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  12. 36 32
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  13. 113 75
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  14. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  15. 0 168
      packages/app/src/components/StickyStretchableScroller.jsx
  16. 125 0
      packages/app/src/components/StickyStretchableScroller.tsx
  17. 2 3
      packages/app/src/components/TableOfContents.jsx
  18. 0 4
      packages/app/src/interfaces/page-listing-results.ts
  19. 35 14
      packages/app/src/server/service/page.ts
  20. 3 3
      packages/app/src/stores/context.tsx
  21. 13 2
      packages/app/src/stores/ui.tsx
  22. 2 2
      packages/app/src/styles/_mixins.scss
  23. 1 0
      packages/app/src/styles/_navbar.scss
  24. 3 0
      packages/app/src/styles/_override-simplebar.scss
  25. 1 1
      packages/app/src/styles/_page-tree.scss
  26. 3 4
      packages/app/src/styles/_sidebar.scss
  27. 3 0
      packages/app/src/styles/_variables.scss
  28. 3 0
      packages/app/src/styles/_vendor.scss
  29. 3 0
      packages/app/src/styles/style-app.scss
  30. 20 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  31. 20 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  32. 0 12
      packages/app/src/styles/theme/_apply-colors.scss
  33. 1 2
      packages/app/src/styles/theme/christmas.scss
  34. 2 2
      packages/app/src/styles/theme/default.scss
  35. 1 2
      packages/app/src/styles/theme/future.scss
  36. 4 2
      packages/app/src/styles/theme/island.scss
  37. 1 2
      packages/app/src/styles/theme/kibela.scss
  38. 3 14
      packages/app/src/styles/theme/mixins/_list-group.scss
  39. 1 1
      packages/app/test/cypress/integration/1-install/install.spec.ts
  40. 10 0
      packages/app/test/cypress/support/index.ts
  41. 318 34
      packages/app/test/integration/service/v5.migration.test.js
  42. 462 0
      packages/app/test/integration/service/v5.non-public-page.test.ts
  43. 1 10
      packages/app/test/integration/service/v5.public-page.test.ts
  44. 1 2
      packages/app/tsconfig.build.server.json
  45. 1 2
      packages/codemirror-textlint/tsconfig.build.json
  46. 1 2
      packages/core/tsconfig.build.cjs.json
  47. 1 2
      packages/core/tsconfig.build.esm.json
  48. 1 2
      packages/plugin-attachment-refs/tsconfig.build.cjs.json
  49. 1 2
      packages/plugin-attachment-refs/tsconfig.build.esm.json
  50. 1 2
      packages/plugin-lsx/tsconfig.build.cjs.json
  51. 1 2
      packages/plugin-lsx/tsconfig.build.esm.json
  52. 1 2
      packages/plugin-pukiwiki-like-linker/tsconfig.build.cjs.json
  53. 1 2
      packages/plugin-pukiwiki-like-linker/tsconfig.build.esm.json
  54. 1 2
      packages/slack/tsconfig.build.json
  55. 1 2
      packages/slackbot-proxy/tsconfig.build.json
  56. 1 2
      packages/ui/tsconfig.build.json
  57. 45 0
      yarn.lock

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -36,6 +36,7 @@ module.exports = {
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:cli:StickyStretchableScroller': 'debug',
+  // 'growi:cli:ItemsTree': 'debug',
   'growi:searchResultList': 'debug',
 
 };

+ 4 - 3
packages/app/package.json

@@ -58,6 +58,8 @@
   },
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
+    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/codemirror-textlint": "^5.0.0-RC.8",
@@ -90,8 +92,6 @@
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
-    "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.7",
-    "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.16.0",
     "diff_match_patch": "^0.1.1",
     "entities": "^2.0.0",
     "esa-node": "^0.2.2",
@@ -187,8 +187,8 @@
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -238,6 +238,7 @@
     "sass": "^1.43.4",
     "sass-loader": "^10.1.1",
     "simple-load-script": "^1.0.2",
+    "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "style-loader": "^1.0.0",

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

@@ -16,7 +16,6 @@ export type AlertInfo = {
 }
 
 type ClosableTextInputProps = {
-  isShown: boolean
   value?: string
   placeholder?: string
   inputValidator?(text: string): AlertInfo | Promise<AlertInfo> | null
@@ -107,7 +106,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   return (
-    <div className={props.isShown ? 'd-block' : 'd-none'}>
+    <div className="d-block flex-fill">
       <input
         value={inputText || ''}
         ref={inputRef}

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

@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
 import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
@@ -212,12 +212,19 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
+  const [shouldFetch, setShouldFetch] = useState(fetchOnInit ?? false);
 
-  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
-  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(shouldMutate ? pageId : null);
+  // update shouldFetch (and will never be false)
+  useEffect(() => {
+    if (shouldFetch) {
+      return;
+    }
+    if (!isIPageInfoForOperation(presetPageInfo) && isOpen) {
+      setShouldFetch(true);
+    }
+  }, [isOpen, presetPageInfo, shouldFetch]);
 
   // mutate after handle event
   const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
@@ -225,10 +232,10 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
       await onClickBookmarkMenuItem(_pageId, _newValue);
     }
 
-    if (shouldMutate) {
+    if (shouldFetch) {
       mutatePageInfo();
     }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
   const isLoading = shouldFetch && fetchedPageInfo == null;
 

+ 1 - 1
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -54,7 +54,7 @@ const AuthorInfo = (props) => {
       </div>
       <div>
         <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date">
+        <div className="text-muted text-date" data-hide-in-vrt>
           {renderParsedDate()}
         </div>
       </div>

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

@@ -256,7 +256,7 @@ const GrowiContextualSubNavigation = (props) => {
             />
           ) }
         </div>
-        <div className={className}>
+        <div className={`${className} ${isCompactMode ? '' : 'mt-2'}`}>
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
               onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -85,7 +85,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
       {/* Right side */}
       <div className="d-flex">
 
-        <div className="d-flex flex-column" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
+        <div className="d-flex flex-column py-md-2" style={{ gap: `${isCompactMode ? '5px' : '0'}` }}>
           { Controls && <Controls></Controls> }
         </div>
 

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

@@ -204,7 +204,7 @@ export const SearchPage = (props: Props): JSX.Element => {
       >
         <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}
           onClick={deleteAllButtonClickedHandler}
         >

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

@@ -61,7 +61,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
   }, [invokeSearch]);
 
   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="flex-grow-1 mx-4">
           <SearchForm

+ 13 - 18
packages/app/src/components/Sidebar.tsx

@@ -9,6 +9,7 @@ import {
   useCurrentSidebarContents,
   useCurrentProductNavWidth,
   useSidebarResizeDisabled,
+  useSidebarScrollerRef,
 } from '~/stores/ui';
 
 import DrawerToggler from './Navbar/DrawerToggler';
@@ -16,7 +17,7 @@ import DrawerToggler from './Navbar/DrawerToggler';
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarContents from './Sidebar/SidebarContents';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
@@ -50,31 +51,25 @@ const GlobalNavigation = () => {
 };
 
 const SidebarContentsWrapper = () => {
-  const [resetKey, setResetKey] = useState(0);
-
-  const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
+  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
 
   const calcViewHeight = useCallback(() => {
-    const scrollTargetElem = document.querySelector('#grw-sidebar-contents-scroll-target');
-    return scrollTargetElem != null
-      ? window.innerHeight - scrollTargetElem?.getBoundingClientRect().top
+    const elem = document.querySelector('#grw-sidebar-contents-wrapper');
+    return elem != null
+      ? window.innerHeight - elem?.getBoundingClientRect().top
       : window.innerHeight;
   }, []);
 
   return (
     <>
-      <StickyStretchableScroller
-        scrollTargetSelector={scrollTargetSelector}
-        contentsElemSelector="#grw-sidebar-content-container"
-        stickyElemSelector=".grw-sidebar"
-        calcViewHeightFunc={calcViewHeight}
-        resetKey={resetKey}
-      />
-
-      <div id="grw-sidebar-contents-scroll-target" style={{ minHeight: '100%' }}>
-        <div id="grw-sidebar-content-container" className="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
+      <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
+        <StickyStretchableScroller
+          simplebarRef={mutateSidebarScroller}
+          stickyElemSelector=".grw-sidebar"
+          calcViewHeight={calcViewHeight}
+        >
           <SidebarContents />
-        </div>
+        </StickyStretchableScroller>
       </div>
 
       <DrawerToggler iconClass="icon-arrow-left" />

+ 2 - 2
packages/app/src/components/Sidebar/PageTree.tsx

@@ -16,10 +16,10 @@ const PageTree: FC = memo(() => {
   const { data: currentPath } = useCurrentPagePath();
   const { data: targetId } = useCurrentPageId();
   const { data: targetAndAncestorsData } = useTargetAndAncestors();
-  const { data: notFoundTargetPathOrIdData } = useNotFoundTargetPathOrId();
+  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
   const { data: migrationStatus } = useSWRxV5MigrationStatus();
 
-  const targetPathOrId = targetId || notFoundTargetPathOrIdData?.notFoundTargetPathOrId;
+  const targetPathOrId = targetId || notFoundTargetPathOrId;
 
   if (migrationStatus == null) {
     return (

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

@@ -36,7 +36,6 @@ interface ItemProps {
   isEnableActions: boolean
   itemNode: ItemNode
   targetPathOrId?: string
-  isScrolled: boolean,
   isOpen?: boolean
   isEnabledAttachTitleHeader?: boolean
   onRenamed?(): void
@@ -102,7 +101,7 @@ type ItemCountProps = {
 const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
   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}
       </span>
     </>
@@ -123,6 +122,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [shouldHide, setShouldHide] = useState(false);
   const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const [isRenaming, setRenaming] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
@@ -273,6 +273,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     try {
       setRenameInputShown(false);
+      setRenaming(true);
       await apiv3Put('/pages/rename', {
         pageId: page._id,
         revisionId: page.revision,
@@ -289,6 +290,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       setRenameInputShown(true);
       toastError(err);
     }
+    finally {
+      setTimeout(() => {
+        setRenaming(false);
+      }, 1000);
+    }
   };
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
@@ -315,7 +321,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const onPressEnterForCreateHandler = async(inputText: string) => {
     setNewPageInputShown(false);
     const parentPath = pathUtils.addTrailingSlash(page.path as string);
-    const newPagePath = `${parentPath}${inputText}`;
+    const newPagePath = nodePath.resolve(parentPath, inputText);
     const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
 
     if (!isCreatable) {
@@ -325,7 +331,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
     let initBody = '';
     if (isEnabledAttachTitleHeader) {
-      const pageTitle = pathUtils.addHeadingSlash(nodePath.basename(newPagePath));
+      const pageTitle = nodePath.basename(newPagePath);
       initBody = pathUtils.attachTitleHeader(pageTitle);
     }
 
@@ -364,12 +370,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   };
 
 
-  useEffect(() => {
-    if (!props.isScrolled && page.isTarget) {
-      document.dispatchEvent(new CustomEvent('targetItemRendered'));
-    }
-  }, [props.isScrolled, page.isTarget]);
-
   // didMount
   useEffect(() => {
     if (hasChildren()) setIsOpen(true);
@@ -404,14 +404,15 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
     >
       <li
         ref={(c) => { drag(c); drop(c) }}
-        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center ${page.isTarget ? 'grw-pagetree-is-target' : ''}`}
-        id={page.isTarget ? 'grw-pagetree-is-target' : `grw-pagetree-list-${page._id}`}
+        className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
+        ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
+        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
       >
         <div className="grw-triangle-container d-flex justify-content-center">
           {hasDescendants && (
             <button
               type="button"
-              className={`grw-pagetree-button btn ${isOpen ? 'grw-pagetree-open' : ''}`}
+              className={`grw-pagetree-triangle-btn btn ${isOpen ? 'grw-pagetree-open' : ''}`}
               onClick={onClickLoadChildren}
             >
               <div className="d-flex justify-content-center">
@@ -420,22 +421,27 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             </button>
           )}
         </div>
-        { isRenameInputShown && (
-          <ClosableTextInput
-            isShown
-            value={nodePath.basename(page.path ?? '')}
-            placeholder={t('Input page name')}
-            onClickOutside={() => { setRenameInputShown(false) }}
-            onPressEnter={onPressEnterForRenameHandler}
-            inputValidator={inputValidator}
-          />
-        )}
-        { !isRenameInputShown && (
-          <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-            <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-          </a>
-        )}
-        {(descendantCount > 0) && (
+        { isRenameInputShown
+          ? (
+            <ClosableTextInput
+              value={nodePath.basename(page.path ?? '')}
+              placeholder={t('Input page name')}
+              onClickOutside={() => { setRenameInputShown(false) }}
+              onPressEnter={onPressEnterForRenameHandler}
+              inputValidator={inputValidator}
+            />
+          )
+          : (
+            <>
+              { isRenaming && (
+                <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
+              )}
+              <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
+                <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+              </a>
+            </>
+          )}
+        {descendantCount > 0 && !isRenameInputShown && (
           <div className="grw-pagetree-count-wrapper">
             <ItemCount descendantCount={descendantCount} />
           </div>
@@ -464,9 +470,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
         </div>
       </li>
 
-      {isEnableActions && (
+      {isEnableActions && isNewPageInputShown && (
         <ClosableTextInput
-          isShown={isNewPageInputShown}
           placeholder={t('Input page name')}
           onClickOutside={() => { setNewPageInputShown(false) }}
           onPressEnter={onPressEnterForCreateHandler}
@@ -480,7 +485,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
               isEnableActions={isEnableActions}
               itemNode={node}
               isOpen={false}
-              isScrolled={props.isScrolled}
               targetPathOrId={targetPathOrId}
               isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
               onRenamed={onRenamed}

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

@@ -1,8 +1,14 @@
-import React, { FC, useEffect, useState } from 'react';
+import React, {
+  useEffect, useRef, useState, useMemo, useCallback,
+} from 'react';
 import { useTranslation } from 'react-i18next';
 
+import { debounce } from 'throttle-debounce';
+
+import loggerFactory from '~/utils/logger';
+
 import { usePageTreeTermManager, useSWRxPageAncestorsChildren, useSWRxRootPage } from '~/stores/page-listing';
-import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
 import { IPageHasId, IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
@@ -10,17 +16,17 @@ import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import {
   IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
 } from '~/stores/modal';
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 
 import { useIsEnabledAttachTitleHeader } from '~/stores/context';
 import { useFullTextSearchTermManager } from '~/stores/search';
 import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
 import { useGlobalSocket } from '~/stores/websocket';
-import { usePageTreeDescCountMap } from '~/stores/ui';
+import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
 
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 
+const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*
  * Utility to generate initial node
@@ -61,6 +67,20 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
 };
 
+// user defined typeguard to assert the arg is not null
+type RenderingCondition = {
+  ancestorsChildrenResult: AncestorsChildrenResult | undefined,
+  rootPageResult: RootPageResult | undefined,
+}
+type SecondStageRenderingCondition = {
+  ancestorsChildrenResult: AncestorsChildrenResult,
+  rootPageResult: RootPageResult,
+}
+const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondStageRenderingCondition): condition is SecondStageRenderingCondition => {
+  return condition.ancestorsChildrenResult != null && condition.rootPageResult != null;
+};
+
+
 type ItemsTreeProps = {
   isEnableActions: boolean
   targetPath: string
@@ -68,86 +88,41 @@ type ItemsTreeProps = {
   targetAndAncestorsData?: TargetAndAncestors
 }
 
-const renderByInitialNode = (
-    initialNode: ItemNode,
-    isEnableActions: boolean,
-    isScrolled: boolean,
-    targetPathOrId?: string,
-    isEnabledAttachTitleHeader?: boolean,
-    onRenamed?: () => void,
-    onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-    onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
-): JSX.Element => {
-
-  return (
-    <ul className="grw-pagetree list-group p-3">
-      <Item
-        key={initialNode.page.path}
-        targetPathOrId={targetPathOrId}
-        itemNode={initialNode}
-        isOpen
-        isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
-        isEnableActions={isEnableActions}
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-        isScrolled={isScrolled}
-      />
-    </ul>
-  );
-};
-
-// --- Auto scroll related vars and util ---
-
-const SCROLL_OFFSET_TOP = window.innerHeight / 2;
-
-const scrollTargetItem = () => {
-  const scrollElement = document.getElementById('grw-sidebar-contents-scroll-target');
-  const target = document.getElementById('grw-pagetree-is-target');
-  if (scrollElement != null && target != null) {
-    smoothScrollIntoView(target, SCROLL_OFFSET_TOP, scrollElement);
-  }
-};
-// --- end ---
-
-
 /*
  * ItemsTree
  */
-const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
     targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions,
   } = props;
 
   const { t } = useTranslation();
 
-  const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
-  const { data: rootPageData, error: error2 } = useSWRxRootPage();
+  const { data: ancestorsChildrenResult, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { data: rootPageResult, error: error2 } = useSWRxRootPage();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const [isScrolled, setIsScrolled] = useState(false);
+  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
 
   const { data: socket } = useGlobalSocket();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
 
-
   // for mutation
   const { advance: advancePt } = usePageTreeTermManager();
   const { advance: advanceFts } = useFullTextSearchTermManager();
   const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
 
-  const scrollItem = () => {
-    scrollTargetItem();
-    setIsScrolled(true);
-  };
+  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
 
-  useEffect(() => {
-    document.addEventListener('targetItemRendered', scrollItem);
-    return () => {
-      document.removeEventListener('targetItemRendered', scrollItem);
+  const rootElemRef = useRef(null);
+
+  const renderingCondition = useMemo(() => {
+    return {
+      ancestorsChildrenResult,
+      rootPageResult,
     };
-  }, []);
+  }, [ancestorsChildrenResult, rootPageResult]);
 
   useEffect(() => {
     if (socket == null) {
@@ -207,35 +182,98 @@ const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   };
 
+  // ***************************  Scroll on init ***************************
+  const scrollOnInit = useCallback(() => {
+    const scrollTargetElement = document.getElementById('grw-pagetree-is-target');
+
+    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
+      return;
+    }
+
+    logger.debug('scrollOnInit has invoked');
+
+    const scrollElement = sidebarScrollerRef.current.getScrollElement();
+
+    // NOTE: could not use scrollIntoView
+    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
+
+    // calculate the center point
+    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
+    scrollElement.scrollTo({ top: scrollTop });
+
+    setIsInitialScrollCompleted(true);
+  }, [sidebarScrollerRef]);
+
+  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
+
+  useEffect(() => {
+    if (!isSecondStageRenderingCondition(renderingCondition) || isInitialScrollCompleted) {
+      return;
+    }
+
+    const rootElement = rootElemRef.current as HTMLElement | null;
+    if (rootElement == null) {
+      return;
+    }
+
+    const observerCallback = (mutationRecords: MutationRecord[]) => {
+      mutationRecords.forEach(() => scrollOnInitDebounced());
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(rootElement, { childList: true, subtree: true });
+
+    // first call for the situation that all rendering is complete at this point
+    scrollOnInitDebounced();
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [isInitialScrollCompleted, renderingCondition, scrollOnInitDebounced]);
+  // *******************************  end  *******************************
+
   if (error1 != null || error2 != null) {
     // TODO: improve message
     toastError('Error occurred while fetching pages to render PageTree');
-    return null;
+    return <></>;
   }
 
+  let initialItemNode;
   /*
-   * Render completely
+   * Render second stage
    */
-  if (ancestorsChildrenData != null && rootPageData != null) {
-    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+  if (isSecondStageRenderingCondition(renderingCondition)) {
+    initialItemNode = generateInitialNodeAfterResponse(
+      renderingCondition.ancestorsChildrenResult.ancestorsChildren,
+      new ItemNode(renderingCondition.rootPageResult.rootPage),
     );
   }
-
   /*
    * Before swr response comes back
    */
-  if (targetAndAncestorsData != null) {
-    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
-    return renderByInitialNode(
-      // eslint-disable-next-line max-len
-      initialNode, isEnableActions, isScrolled, targetPathOrId, isEnabledAttachTitleHeader, onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+  else if (targetAndAncestorsData != null) {
+    initialItemNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+  }
+
+  if (initialItemNode != null) {
+    return (
+      <ul className="grw-pagetree list-group p-3" ref={rootElemRef}>
+        <Item
+          key={initialItemNode.page.path}
+          targetPathOrId={targetPathOrId}
+          itemNode={initialItemNode}
+          isOpen
+          isEnabledAttachTitleHeader={isEnabledAttachTitleHeader}
+          isEnableActions={isEnableActions}
+          onRenamed={onRenamed}
+          onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+          onClickDeleteMenuItem={onClickDeleteMenuItem}
+        />
+      </ul>
     );
   }
 
-  return null;
+  return <></>;
 };
 
 export default ItemsTree;

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -121,7 +121,7 @@ SmallPageItem.propTypes = {
 };
 
 
-const RecentChanges: FC<void> = () => {
+const RecentChanges = (): JSX.Element => {
 
   const { t } = useTranslation();
   const { data: pages, mutate } = useSWRxRecentlyUpdated();

+ 0 - 168
packages/app/src/components/StickyStretchableScroller.jsx

@@ -1,168 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-import PropTypes from 'prop-types';
-
-import { debounce } from 'throttle-debounce';
-import StickyEvents from 'sticky-events';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:cli:StickyStretchableScroller');
-
-
-/**
- * USAGE:
- *
-  const calcViewHeight = useCallback(() => {
-    const containerElem = document.querySelector('#sticky-elem');
-    const containerTop = containerElem.getBoundingClientRect().top;
-
-    // stretch to the bottom of window
-    return window.innerHeight - containerTop;
-  });
-
-  return (
-    <StickyStretchableScroller
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    >
-      <div id="scroll-elem">
-        ...
-      </div>
-    </StickyStretchableScroller>
-  );
-
-  or
-
-  return (
-    <StickyStretchableScroller
-      scrollTargetId="scroll-elem"
-      contentsElemSelector="#long-contents-elem"
-      stickyElemSelector="#sticky-elem"
-      calcViewHeightFunc={calcViewHeight}
-    />
-  );
- */
-const StickyStretchableScroller = (props) => {
-
-  let { scrollTargetSelector } = props;
-  const {
-    children, contentsElemSelector, stickyElemSelector,
-    calcViewHeightFunc, calcContentsHeightFunc,
-    resetKey,
-  } = props;
-
-  if (scrollTargetSelector == null && children == null) {
-    throw new Error('Either of scrollTargetSelector or children is required');
-  }
-
-  if (scrollTargetSelector == null) {
-    scrollTargetSelector = `#${children.props.id}`;
-  }
-
-  /**
-   * Reset scrollbar
-   */
-  const resetScrollbar = useCallback(() => {
-    const contentsElem = document.querySelector(contentsElemSelector);
-    if (contentsElem == null) {
-      return;
-    }
-
-    const viewHeight = calcViewHeightFunc != null
-      ? calcViewHeightFunc()
-      : 'auto';
-    const contentsHeight = calcContentsHeightFunc != null
-      ? calcContentsHeightFunc(contentsElem)
-      : contentsElem.getBoundingClientRect().height;
-
-    logger.debug(`[${scrollTargetSelector}] viewHeight`, viewHeight);
-    logger.debug(`[${scrollTargetSelector}] contentsHeight`, contentsHeight);
-
-    const isScrollEnabled = viewHeight === 'auto' || (viewHeight < contentsHeight);
-
-    $(scrollTargetSelector).slimScroll({
-      color: '#666',
-      railColor: '#999',
-      railVisible: true,
-      position: 'right',
-      height: isScrollEnabled ? viewHeight : contentsHeight,
-      wheelStep: 10,
-      allowPageScroll: true,
-    });
-
-    // destroy
-    if (!isScrollEnabled) {
-      $(scrollTargetSelector).slimScroll({ destroy: true });
-    }
-
-  }, [contentsElemSelector, calcViewHeightFunc, calcContentsHeightFunc, scrollTargetSelector]);
-
-  const resetScrollbarDebounced = debounce(100, resetScrollbar);
-
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setTimeout(resetScrollbar, 100);
-  }, [resetScrollbar]);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    if (stickyElemSelector == null) {
-      return;
-    }
-
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyElemSelector, stickyChangeHandler]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = (event) => {
-      resetScrollbarDebounced();
-    };
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [resetScrollbarDebounced]);
-
-  // setup effect on init
-  useEffect(() => {
-    if (resetKey != null) {
-      resetScrollbarDebounced();
-    }
-  }, [resetKey, resetScrollbarDebounced]);
-
-  return (
-    <>
-      { children }
-    </>
-  );
-};
-
-StickyStretchableScroller.propTypes = {
-  contentsElemSelector: PropTypes.string.isRequired,
-
-  children: PropTypes.node,
-  scrollTargetSelector: PropTypes.string,
-  stickyElemSelector: PropTypes.string,
-
-  resetKey: PropTypes.any,
-
-  calcViewHeightFunc: PropTypes.func,
-  calcContentsHeightFunc: PropTypes.func,
-};
-
-export default StickyStretchableScroller;

+ 125 - 0
packages/app/src/components/StickyStretchableScroller.tsx

@@ -0,0 +1,125 @@
+import React, {
+  useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
+} from 'react';
+
+import { debounce } from 'throttle-debounce';
+import StickyEvents from 'sticky-events';
+import SimpleBar from 'simplebar-react';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:cli:StickyStretchableScroller');
+
+
+export type StickyStretchableScrollerProps = {
+  stickyElemSelector: string,
+  simplebarRef?: (ref: RefObject<SimpleBar>) => void,
+  calcViewHeight?: (scrollElement: HTMLElement) => number,
+  children?: ReactNode,
+}
+
+/**
+ * USAGE:
+ *
+  const calcViewHeight = useCallback(() => {
+    const containerElem = document.querySelector('#sticky-elem');
+    const containerTop = containerElem.getBoundingClientRect().top;
+
+    // stretch to the bottom of window
+    return window.innerHeight - containerTop;
+  });
+
+  return (
+    <StickyStretchableScroller
+      stickyElemSelector="#sticky-elem"
+      calcViewHeight={calcViewHeight}
+    >
+      <div>
+        ...
+      </div>
+    </StickyStretchableScroller>
+  );
+ */
+export const StickyStretchableScroller = (props: StickyStretchableScrollerProps): JSX.Element => {
+
+  const {
+    children, stickyElemSelector, calcViewHeight, simplebarRef: setSimplebarRef,
+  } = props;
+
+  const simplebarRef = useRef<SimpleBar>(null);
+  const [simplebarMaxHeight, setSimplebarMaxHeight] = useState<number|undefined>();
+
+  /**
+   * Reset scrollbar
+   */
+  const resetScrollbar = useCallback(() => {
+    if (simplebarRef.current == null || calcViewHeight == null) {
+      return;
+    }
+
+    const scrollElement = simplebarRef.current.getScrollElement();
+    const newHeight = calcViewHeight(scrollElement);
+
+    logger.debug('Set new height to simplebar', newHeight);
+
+    // set new height
+    setSimplebarMaxHeight(newHeight);
+    // reculculate
+    simplebarRef.current.recalculate();
+  }, [calcViewHeight]);
+
+  const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
+
+  const stickyChangeHandler = useCallback(() => {
+    logger.debug('StickyEvents.CHANGE detected');
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+    stickyEvents.enableEvents();
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyElemSelector, stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = () => {
+      resetScrollbarDebounced();
+    };
+
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [resetScrollbarDebounced]);
+
+  // setup effect on init
+  useEffect(() => {
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // update ref
+  useEffect(() => {
+    if (setSimplebarRef != null) {
+      setSimplebarRef(simplebarRef);
+    }
+  }, [setSimplebarRef]);
+
+  return (
+    <SimpleBar style={{ maxHeight: simplebarMaxHeight }} ref={simplebarRef}>
+      { children }
+    </SimpleBar>
+  );
+};

+ 2 - 3
packages/app/src/components/TableOfContents.jsx

@@ -10,7 +10,7 @@ import { blinkElem } from '~/client/util/blink-section-header';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
-import StickyStretchableScroller from './StickyStretchableScroller';
+import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
@@ -56,9 +56,8 @@ const TableOfContents = (props) => {
 
   return (
     <StickyStretchableScroller
-      contentsElemSelector=".revision-toc .markdownIt-TOC"
       stickyElemSelector=".grw-side-contents-sticky-container"
-      calcViewHeightFunc={calcViewHeight}
+      calcViewHeight={calcViewHeight}
     >
       { tocHtml !== ''
         ? (

+ 0 - 4
packages/app/src/interfaces/page-listing-results.ts

@@ -23,10 +23,6 @@ export interface TargetAndAncestors {
 }
 
 
-export interface NotFoundTargetPathOrId {
-  notFoundTargetPathOrId: string
-}
-
 export interface IsNotFoundPermalink {
   isNotFoundPermalink: boolean
 }

+ 35 - 14
packages/app/src/server/service/page.ts

@@ -304,7 +304,7 @@ class PageService {
     const isRoot = isTopPage(page.path);
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
-    const shouldUseV4Process = !isRoot && !isPageRestricted && (!isV5Compatible || !isPageMigrated || isTrashPage);
+    const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
 
     return shouldUseV4Process;
   }
@@ -315,7 +315,7 @@ class PageService {
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
-    const shouldUseV4Process = !isPageRestricted && !isV5Compatible;
+    const shouldUseV4Process = !isV5Compatible || isPageRestricted;
 
     return shouldUseV4Process;
   }
@@ -533,18 +533,15 @@ class PageService {
     const newParentPath = pathlib.dirname(toPath);
 
     // 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);
     if (originalParent == null) {
       throw Error('Original parent not found');
@@ -2372,6 +2369,9 @@ class PageService {
       // find pages again to get updated descendantCount
       // then calculate inc
       const pageAfterUpdatingDescendantCount = await Page.findByIdAndViewer(page._id, user);
+      if (pageAfterUpdatingDescendantCount == null) {
+        throw Error('Page not found after updating descendantCount');
+      }
 
       const exDescendantCount = page.descendantCount;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
@@ -2571,17 +2571,38 @@ class PageService {
       async write(pages, encoding, callback) {
         const parentPaths = Array.from(new Set<string>(pages.map(p => pathlib.dirname(p.path))));
 
-        // 1. Remove unnecessary 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);
 
         // 3. Find parents
-        const builder = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
-        const parents = await builder
+        const builder2 = new PageQueryBuilder(Page.find({}, { _id: 1, path: 1 }), true);
+        const parents = await builder2
           .addConditionToListByPathsArray(parentPaths)
           .query
           .lean()

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

@@ -7,7 +7,7 @@ import { IUser } from '../interfaces/user';
 
 import { useStaticSWR } from './use-static-swr';
 
-import { TargetAndAncestors, NotFoundTargetPathOrId, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
 
 type Nullable<T> = T | null;
 
@@ -132,8 +132,8 @@ export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResp
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 
-export const useNotFoundTargetPathOrId = (initialData?: Nullable<NotFoundTargetPathOrId>): SWRResponse<Nullable<NotFoundTargetPathOrId>, Error> => {
-  return useStaticSWR<Nullable<NotFoundTargetPathOrId>, Error>('notFoundTargetPathOrId', initialData);
+export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
 };
 
 export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {

+ 13 - 2
packages/app/src/stores/ui.tsx

@@ -1,12 +1,14 @@
-import useSWR, {
+import { RefObject } from 'react';
+import {
   useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import SimpleBar from 'simplebar-react';
+
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { pagePathUtils } from '@growi/core';
 
-import { RefObject } from 'react';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -38,6 +40,15 @@ export const EditorMode = {
 export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
 
 
+/** **********************************************************
+ *                     Storing RefObjects
+ *********************************************************** */
+
+export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
+  return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
+};
+
+
 /** **********************************************************
  *                          SWR Hooks
  *                      for switching UI

+ 2 - 2
packages/app/src/styles/_mixins.scss

@@ -206,7 +206,7 @@
   }
   @include hover() {
     svg {
-      fill: $value;
+      fill: $color-hover;
     }
   }
   &.disabled,
@@ -219,7 +219,7 @@
   &:not(:disabled):not(.disabled).active,
   .show > &.dropdown-toggle {
     svg {
-      fill: $value;
+      fill: $color-hover;
     }
   }
 }

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

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

+ 3 - 0
packages/app/src/styles/_override-simplebar.scss

@@ -0,0 +1,3 @@
+.simplebar-scrollbar::before {
+  background-color: #666;
+}

+ 1 - 1
packages/app/src/styles/_page-tree.scss

@@ -26,7 +26,7 @@ $grw-pagetree-item-padding-left: 10px;
       }
     }
 
-    .grw-pagetree-button {
+    .grw-pagetree-triangle-btn {
       background-color: transparent;
       transition: all 0.2s ease-out;
       transform: rotate(0deg);

+ 3 - 4
packages/app/src/styles/_sidebar.scss

@@ -74,13 +74,12 @@
             flex-direction: column;
             width: 100%;
             height: 100%;
-            overflow: hidden auto;
+            overflow: hidden;
           }
         }
       }
 
-      .grw-sidebar-content-container {
-        position: relative;
+      .simplebar-mask {
         z-index: 110; // greater than the value of .grw-navigation-draggable to fix https://redmine.weseek.co.jp/issues/86678
       }
     }
@@ -89,7 +88,7 @@
       top: 0px;
       bottom: 0px;
       left: 100%;
-      z-index: 100; // greater than the value of slimScrollBar
+      z-index: 10; // greater than the value of SimpleBar
       width: 0;
       .grw-navigation-draggable-hitarea {
         position: relative;

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

@@ -15,6 +15,9 @@ $font-family-monospace-not-strictly: Monaco, Menlo, Consolas, 'Courier New', Mei
 //== Layout
 $grw-navbar-height: 52px;
 $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-md: 115px;

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

@@ -23,3 +23,6 @@
 
 // import Handsontable styles
 @import '~handsontable/dist/handsontable.full.css';
+
+// import SimpleBar styles
+@import '~simplebar/dist/simplebar.min.css';

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

@@ -16,6 +16,9 @@
 // override react-bootstrap-typeahead styles
 @import 'override-rbt';
 
+// override simplebar-react styles
+@import 'override-simplebar';
+
 // atoms
 @import 'atoms/buttons';
 @import 'atoms/code';

+ 20 - 2
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -281,9 +281,15 @@ ul.pagination {
       $color-list-hover,
       $bgcolor-list-hover,
       $color-list-active,
-      lighten($bgcolor-list-hover, 5%),
-      $gray-500
+      lighten($bgcolor-list-hover, 5%)
     );
+    .grw-pagetree-triangle-btn {
+      @include button-outline-svg-icon-variant($secondary, $gray-200);
+    }
+    .grw-pagetree-count {
+      color: $gray-400;
+      background: $gray-700;
+    }
   }
   .private-legacy-pages-link {
     &:hover {
@@ -292,6 +298,18 @@ ul.pagination {
   }
 }
 
+.btn.btn-page-item-control {
+  @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
+  @include hover() {
+    background-color: $gray-600;
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $gray-200;
+  }
+  box-shadow: none !important;
+}
+
 /*
  * Popover
  */

+ 20 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -176,9 +176,15 @@ $border-color: $border-color-global;
       $color-list-hover,
       $bgcolor-list-hover,
       $color-list-active,
-      $bgcolor-list-active,
-      $gray-400
+      $bgcolor-list-active
     );
+    .grw-pagetree-triangle-btn {
+      @include button-outline-svg-icon-variant($gray-400, $primary);
+    }
+    .grw-pagetree-count {
+      color: $gray-500;
+      background: $gray-200;
+    }
   }
   .private-legacy-pages-link {
     &:hover {
@@ -187,6 +193,18 @@ $border-color: $border-color-global;
   }
 }
 
+.btn.btn-page-item-control {
+  @include button-outline-variant($gray-500, $primary, lighten($primary, 52%), transparent);
+  @include hover() {
+    background-color: lighten($primary, 58%);
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active {
+    color: $primary;
+  }
+  box-shadow: none !important;
+}
+
 /*
  * GROWI page list
  */

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

@@ -710,18 +710,6 @@ mark.rbt-highlight-text {
   }
 }
 
-// Page Management Dropdown icon
-.btn-page-item-control {
-  color: $gray-500;
-  &:hover,
-  &:focus {
-    background-color: rgba($color-link, 0.15);
-  }
-  &:active {
-    background-color: rgba($color-link, 0.2);
-  }
-}
-
 /*
   Slack Integration
 */

+ 1 - 2
packages/app/src/styles/theme/christmas.scss

@@ -194,8 +194,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        $bgcolor-list-hover,
-        $gray-400
+        $bgcolor-list-hover
       );
     }
   }

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

@@ -72,7 +72,7 @@ html[light] {
   $bgcolor-resize-button-hover: lighten($bgcolor-resize-button, 5%);
   // Sidebar contents
   $color-sidebar-context: $color-global;
-  $bgcolor-sidebar-context: $gray-100;
+  $bgcolor-sidebar-context: lighten($primary, 77%);
   // Sidebar list group
   $bgcolor-sidebar-list-group: $gray-50; // optional
 
@@ -172,7 +172,7 @@ html[dark] {
   $color-resize-button-hover: white;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
-  $bgcolor-sidebar-context: #111d2f;
+  $bgcolor-sidebar-context: lighten($bgcolor-global, 10%);
   $color-sidebar-context: $color-global;
   // Sidebar list group
   $bgcolor-sidebar-list-group: #1c2a3e; // optional

+ 1 - 2
packages/app/src/styles/theme/future.scss

@@ -122,8 +122,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-hover, 5%),
-        $gray-600
+        lighten($bgcolor-list-hover, 5%)
       );
     }
   }

+ 4 - 2
packages/app/src/styles/theme/island.scss

@@ -131,9 +131,11 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-hover, 5%),
-        $gray-400
+        lighten($bgcolor-list-hover, 5%)
       );
+      .grw-pagetree-triangle-btn {
+        @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
+      }
     }
   }
 }

+ 1 - 2
packages/app/src/styles/theme/kibela.scss

@@ -122,8 +122,7 @@ html[dark] {
         $color-list-hover,
         $bgcolor-list-hover,
         $color-list-active,
-        lighten($bgcolor-list-active, 55%),
-        $gray-400
+        lighten($bgcolor-list-active, 55%)
       );
     }
   }

+ 3 - 14
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -24,8 +24,7 @@
   $color-hover: $color,
   $bgcolor-hover: $bgcolor,
   $color-active: $color,
-  $bgcolor-active: $bgcolor,
-  $button-color
+  $bgcolor-active: $bgcolor
 ) {
   .grw-pagetree-is-over {
     background: $bgcolor-hover;
@@ -35,18 +34,8 @@
     background-color: transparent;
     border-color: $border-color-global;
 
-    &.grw-pagetree-is-target {
-      background: $bgcolor-active;
-    }
-    .grw-pagetree-count {
-      background: $bgcolor;
-    }
-    .grw-pagetree-button {
-      &:not(:hover) {
-        svg {
-          fill: $button-color;
-        }
-      }
+    &.grw-pagetree-current-page-item {
+      background: $bgcolor-hover;
     }
 
     &.list-group-item-action {

+ 1 - 1
packages/app/test/cypress/integration/1-install/install.spec.ts

@@ -53,7 +53,7 @@ context('Installing', () => {
     cy.getByTestid('btnSubmit').click();
 
     cy.screenshot(`${ssPrefix}-installed`, {
-      blackout: ['.grw-sidebar-content-container'],
+      blackout: ['#grw-sidebar-contents-wrapper'],
     });
   });
 

+ 10 - 0
packages/app/test/cypress/support/index.ts

@@ -20,6 +20,16 @@ import './screenshot'
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 
+// Ignore 'ResizeObserver loop limit exceeded' exception
+// https://github.com/cypress-io/cypress/issues/8418
+const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
+Cypress.on('uncaught:exception', (err) => {
+    /* returning false here prevents Cypress from failing the test */
+    if (resizeObserverLoopErrRe.test(err.message)) {
+        return false
+    }
+})
+
 declare global {
   // eslint-disable-next-line @typescript-eslint/no-namespace
   namespace Cypress {

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

@@ -6,64 +6,245 @@ describe('V5 page migration', () => {
   let crowi;
   let Page;
   let User;
+  let UserGroup;
+  let UserGroupRelation;
 
   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() => {
     jest.restoreAllMocks();
 
     crowi = await getInstance();
     Page = mongoose.model('Page');
     User = mongoose.model('User');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
 
     await crowi.configManager.updateConfigsInTheSameNamespace('crowi', { 'app:isV5Compatible': true });
 
     await User.insertMany([{ name: 'testUser1', username: 'testUser1', email: 'testUser1@example.com' }]);
     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()', () => {
+
+    const normalizeParentRecursivelyByPages = async(pages, user) => {
+      return crowi.pageService.normalizeParentRecursivelyByPages(pages, user);
+    };
+
     test('should migrate all pages specified by pageIds', async() => {
       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'] } });
 
       // migrate
-      await crowi.pageService.normalizeParentRecursivelyByPages(pagesToRun, testUser1);
-
+      await normalizeParentRecursivelyByPages(pagesToRun, testUser1);
       const migratedPages = await Page.find({
         path: {
           $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());
     });
 
+    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()', () => {
@@ -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() => {
     const replacedPathPages = await Page.find({ path: '/publicA/privateB' }); // ex-private page
 

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

@@ -0,0 +1,462 @@
+/* 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 groupIdA;
+  let groupIdB;
+  let groupIdC;
+  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();
+    });
+  };
+
+  /**
+   * Revert
+   */
+  // page id
+  const pageIdRevert1 = new mongoose.Types.ObjectId();
+  const pageIdRevert2 = new mongoose.Types.ObjectId();
+  const pageIdRevert3 = new mongoose.Types.ObjectId();
+  const pageIdRevert4 = new mongoose.Types.ObjectId();
+  const pageIdRevert5 = new mongoose.Types.ObjectId();
+  const pageIdRevert6 = new mongoose.Types.ObjectId();
+  // revision id
+  const revisionIdRevert1 = new mongoose.Types.ObjectId();
+  const revisionIdRevert2 = new mongoose.Types.ObjectId();
+  const revisionIdRevert3 = new mongoose.Types.ObjectId();
+  const revisionIdRevert4 = new mongoose.Types.ObjectId();
+  const revisionIdRevert5 = new mongoose.Types.ObjectId();
+  const revisionIdRevert6 = new mongoose.Types.ObjectId();
+  // tag id
+  const tagIdRevert1 = new mongoose.Types.ObjectId();
+  const tagIdRevert2 = new mongoose.Types.ObjectId();
+
+  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');
+    UserGroup = mongoose.model('UserGroup');
+    UserGroupRelation = mongoose.model('UserGroupRelation');
+
+    /*
+     * Common
+     */
+
+    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();
+    groupIdA = new mongoose.Types.ObjectId();
+    groupIdB = new mongoose.Types.ObjectId();
+    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);
+
+    dummyUser1 = await User.findOne({ username: 'v5DummyUser1' });
+    dummyUser2 = await User.findOne({ username: 'v5DummyUser2' });
+    npDummyUser1 = await User.findOne({ username: 'npUser1' });
+    npDummyUser2 = await User.findOne({ username: 'npUser2' });
+    npDummyUser3 = await User.findOne({ username: 'npUser3' });
+
+    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
+     */
+    await Page.insertMany([
+      {
+        _id: pageIdRevert1,
+        path: '/trash/np_revert1',
+        grant: Page.GRANT_RESTRICTED,
+        revision: revisionIdRevert1,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdRevert2,
+        path: '/trash/np_revert2',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        revision: revisionIdRevert2,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdRevert3,
+        path: '/trash/np_revert3',
+        revision: revisionIdRevert3,
+        status: Page.STATUS_DELETED,
+        parent: rootPage._id,
+      },
+      {
+        _id: pageIdRevert4,
+        path: '/trash/np_revert3/middle/np_revert4',
+        grant: Page.GRANT_RESTRICTED,
+        revision: revisionIdRevert4,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdRevert5,
+        path: '/trash/np_revert5',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdA,
+        revision: revisionIdRevert5,
+        status: Page.STATUS_DELETED,
+      },
+      {
+        _id: pageIdRevert6,
+        path: '/trash/np_revert5/middle/np_revert6',
+        grant: Page.GRANT_USER_GROUP,
+        grantedGroup: groupIdB,
+        revision: revisionIdRevert6,
+        status: Page.STATUS_DELETED,
+      },
+    ]);
+    await Revision.insertMany([
+      {
+        _id: revisionIdRevert1,
+        pageId: pageIdRevert1,
+        body: 'np_revert1',
+        format: 'markdown',
+        author: dummyUser1._id,
+      },
+      {
+        _id: revisionIdRevert2,
+        pageId: pageIdRevert2,
+        body: 'np_revert2',
+        format: 'markdown',
+        author: npDummyUser1,
+      },
+      {
+        _id: revisionIdRevert3,
+        pageId: pageIdRevert3,
+        body: 'np_revert3',
+        format: 'markdown',
+        author: npDummyUser1,
+      },
+      {
+        _id: revisionIdRevert4,
+        pageId: pageIdRevert4,
+        body: 'np_revert4',
+        format: 'markdown',
+        author: npDummyUser1,
+      },
+      {
+        _id: revisionIdRevert5,
+        pageId: pageIdRevert5,
+        body: 'np_revert5',
+        format: 'markdown',
+        author: npDummyUser1,
+      },
+      {
+        _id: revisionIdRevert6,
+        pageId: pageIdRevert6,
+        body: 'np_revert6',
+        format: 'markdown',
+        author: npDummyUser1,
+      },
+    ]);
+
+    await Tag.insertMany([
+      { _id: tagIdRevert1, name: 'np_revertTag1' },
+      { _id: tagIdRevert2, name: 'np_revertTag2' },
+    ]);
+
+    await PageTagRelation.insertMany([
+      {
+        relatedPage: pageIdRevert1,
+        relatedTag: tagIdRevert1,
+        isPageTrashed: true,
+      },
+      {
+        relatedPage: pageIdRevert2,
+        relatedTag: tagIdRevert2,
+        isPageTrashed: true,
+      },
+    ]);
+  });
+
+  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', () => {
+    const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
+      // mock return value
+      const mockedRevertRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'revertRecursivelyMainOperation').mockReturnValue(null);
+      const revertedPage = await crowi.pageService.revertDeletedPage(page, user, options, isRecursively);
+
+      const argsForRecursivelyMainOperation = mockedRevertRecursivelyMainOperation.mock.calls[0];
+
+      // restores the original implementation
+      mockedRevertRecursivelyMainOperation.mockRestore();
+      if (isRecursively) {
+        await crowi.pageService.revertRecursivelyMainOperation(...argsForRecursivelyMainOperation);
+      }
+
+      return revertedPage;
+
+    };
+    test('should revert single deleted page with GRANT_RESTRICTED', async() => {
+      const trashedPage = await Page.findOne({ path: '/trash/np_revert1', status: Page.STATUS_DELETED, grant: Page.GRANT_RESTRICTED });
+      const revision = await Revision.findOne({ pageId: trashedPage._id });
+      const tag = await Tag.findOne({ name: 'np_revertTag1' });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      expectAllToBeTruthy([trashedPage, revision, tag, deletedPageTagRelation]);
+
+      await revertDeletedPage(trashedPage, dummyUser1, {}, false);
+      const revertedPage = await Page.findOne({ path: '/np_revert1' });
+      const deltedPageBeforeRevert = await Page.findOne({ path: '/trash/np_revert1' });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      expectAllToBeTruthy([revertedPage, pageTagRelation]);
+
+      expect(deltedPageBeforeRevert).toBeNull();
+
+      // page with GRANT_RESTRICTED does not have parent
+      expect(revertedPage.parent).toBeNull();
+      expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(revertedPage.grant).toBe(Page.GRANT_RESTRICTED);
+      expect(pageTagRelation.isPageTrashed).toBe(false);
+    });
+    test('should revert single deleted page with GRANT_USER_GROUP', async() => {
+      const beforeRevertPath = '/trash/np_revert2';
+      const user1 = await User.findOne({ name: 'npUser1' });
+      const trashedPage = await Page.findOne({ path: beforeRevertPath, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
+      const revision = await Revision.findOne({ pageId: trashedPage._id });
+      const tag = await Tag.findOne({ name: 'np_revertTag2' });
+      const deletedPageTagRelation = await PageTagRelation.findOne({ relatedPage: trashedPage._id, relatedTag: tag._id, isPageTrashed: true });
+      expectAllToBeTruthy([trashedPage, revision, tag, deletedPageTagRelation]);
+
+      await revertDeletedPage(trashedPage, user1, {}, false);
+      const revertedPage = await Page.findOne({ path: '/np_revert2' });
+      const trashedPageBR = await Page.findOne({ path: beforeRevertPath });
+      const pageTagRelation = await PageTagRelation.findOne({ relatedPage: revertedPage._id, relatedTag: tag._id });
+      expectAllToBeTruthy([revertedPage, pageTagRelation]);
+      expect(trashedPageBR).toBeNull();
+
+      expect(revertedPage.parent).toStrictEqual(rootPage._id);
+      expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(revertedPage.grant).toBe(Page.GRANT_USER_GROUP);
+      expect(revertedPage.grantedGroup).toStrictEqual(groupIdA);
+      expect(pageTagRelation.isPageTrashed).toBe(false);
+    });
+    test(`revert multiple pages: only target page should be reverted.
+          Non-existant middle page and leaf page with GRANT_RESTRICTED shoud not be reverted`, async() => {
+      const beforeRevertPath1 = '/trash/np_revert3';
+      const beforeRevertPath2 = '/trash/np_revert3/middle/np_revert4';
+      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grant: Page.GRANT_PUBLIC });
+      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grant: Page.GRANT_RESTRICTED });
+      const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
+      const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
+      expectAllToBeTruthy([trashedPage1, trashedPage2, revision1, revision2]);
+
+      await revertDeletedPage(trashedPage1, npDummyUser2, {}, true);
+      const revertedPage = await Page.findOne({ path: '/np_revert3' });
+      const middlePage = await Page.findOne({ path: '/np_revert3/middle' });
+      const notRestrictedPage = await Page.findOne({ path: '/np_revert3/middle/np_revert4' });
+      // AR => After Revert
+      const trashedPage1AR = await Page.findOne({ path: beforeRevertPath1 });
+      const trashedPage2AR = await Page.findOne({ path: beforeRevertPath2 });
+      const revision1AR = await Revision.findOne({ pageId: revertedPage._id });
+      const revision2AR = await Revision.findOne({ pageId: trashedPage2AR._id });
+      expectAllToBeTruthy([revertedPage, trashedPage2AR, revision1AR, revision2AR]);
+      expect(trashedPage1AR).toBeNull();
+      expect(notRestrictedPage).toBeNull();
+      expect(middlePage).toBeNull();
+
+      expect(revertedPage.parent).toStrictEqual(rootPage._id);
+      expect(revertedPage.status).toBe(Page.STATUS_PUBLISHED);
+      expect(revertedPage.grant).toBe(Page.GRANT_PUBLIC);
+    });
+    test('revert multiple pages: target page, initially non-existant page and leaf page with GRANT_USER_GROUP shoud be reverted', async() => {
+      const user = await User.findOne({ _id: npDummyUser3 });
+      const beforeRevertPath1 = '/trash/np_revert5';
+      const beforeRevertPath2 = '/trash/np_revert5/middle/np_revert6';
+      const beforeRevertPath3 = '/trash/np_revert5/middle';
+      const trashedPage1 = await Page.findOne({ path: beforeRevertPath1, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
+      const trashedPage2 = await Page.findOne({ path: beforeRevertPath2, status: Page.STATUS_DELETED, grant: Page.GRANT_USER_GROUP });
+      const nonExistantPage3 = await Page.findOne({ path: beforeRevertPath3 }); // not exist
+      const revision1 = await Revision.findOne({ pageId: trashedPage1._id });
+      const revision2 = await Revision.findOne({ pageId: trashedPage2._id });
+      expectAllToBeTruthy([trashedPage1, trashedPage2, revision1, revision2, user]);
+      expect(nonExistantPage3).toBeNull();
+
+      await revertDeletedPage(trashedPage1, user, {}, true);
+      const revertedPage1 = await Page.findOne({ path: '/np_revert5' });
+      const newlyCreatedPage = await Page.findOne({ path: '/np_revert5/middle' });
+      const revertedPage2 = await Page.findOne({ path: '/np_revert5/middle/np_revert6' });
+
+      // // AR => After Revert
+      const trashedPage1AR = await Page.findOne({ path: beforeRevertPath1 });
+      const trashedPage2AR = await Page.findOne({ path: beforeRevertPath2 });
+      expectAllToBeTruthy([revertedPage1, newlyCreatedPage, revertedPage2]);
+      expect(trashedPage1AR).toBeNull();
+      expect(trashedPage2AR).toBeNull();
+
+      expect(newlyCreatedPage.isEmpty).toBe(true);
+
+      expect(revertedPage1.parent).toStrictEqual(rootPage._id);
+      expect(revertedPage2.parent).toStrictEqual(newlyCreatedPage._id);
+      expect(newlyCreatedPage.parent).toStrictEqual(revertedPage1._id);
+
+      expect(revertedPage1.status).toBe(Page.STATUS_PUBLISHED);
+      expect(revertedPage2.status).toBe(Page.STATUS_PUBLISHED);
+      expect(newlyCreatedPage.status).toBe(Page.STATUS_PUBLISHED);
+
+      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
+      expect(revertedPage1.grant).toBe(Page.GRANT_USER_GROUP);
+      expect(newlyCreatedPage.grant).toBe(Page.GRANT_PUBLIC);
+
+    });
+  });
+});

+ 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;
 
-  /* 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]
   const expectAllToBeTruthy = (dataList) => {
@@ -901,7 +899,7 @@ describe('PageService page operations with only public pages', () => {
   describe('Rename', () => {
 
     const renamePage = async(page, newPagePath, user, options) => {
-    // mock return value
+      // mock return value
       const mockedRenameSubOperation = jest.spyOn(crowi.pageService, 'renameSubOperation').mockReturnValue(null);
       const mockedCreateAndSendNotifications = jest.spyOn(crowi.pageService, 'createAndSendNotifications').mockReturnValue(null);
       const renamedPage = await crowi.pageService.renamePage(page, newPagePath, user, options);
@@ -1166,7 +1164,6 @@ describe('PageService page operations with only public pages', () => {
       expect(renamedPageGrandchild.isEmpty).toBe(false);
     });
   });
-
   describe('Duplicate', () => {
 
     const duplicate = async(page, newPagePath, user, isRecursively) => {
@@ -1444,7 +1441,6 @@ describe('PageService page operations with only public pages', () => {
       expect(deletedTagRelation2.isPageTrashed).toBe(true);
     });
   });
-
   describe('Delete completely', () => {
     const deleteCompletely = async(page, user, options = {}, isRecursively = false, preventEmitting = false) => {
       const mockedDeleteCompletelyRecursivelyMainOperation = jest.spyOn(crowi.pageService, 'deleteCompletelyRecursivelyMainOperation').mockReturnValue(null);
@@ -1562,8 +1558,6 @@ describe('PageService page operations with only public pages', () => {
 
     });
   });
-
-
   describe('revert', () => {
     const revertDeletedPage = async(page, user, options = {}, isRecursively = false) => {
       // 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
-});

+ 1 - 2
packages/app/tsconfig.build.server.json

@@ -5,9 +5,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
     "baseUrl": ".",
     "paths": {
       "~/*": ["./src/*"],

+ 1 - 2
packages/codemirror-textlint/tsconfig.build.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/core/tsconfig.build.cjs.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/core/tsconfig.build.esm.json

@@ -8,9 +8,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-attachment-refs/tsconfig.build.cjs.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-attachment-refs/tsconfig.build.esm.json

@@ -8,9 +8,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-lsx/tsconfig.build.cjs.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-lsx/tsconfig.build.esm.json

@@ -8,9 +8,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-pukiwiki-like-linker/tsconfig.build.cjs.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/plugin-pukiwiki-like-linker/tsconfig.build.esm.json

@@ -8,9 +8,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/slack/tsconfig.build.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 1 - 2
packages/slackbot-proxy/tsconfig.build.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
     "baseUrl": "./src",
     "paths": {
       "~/*": ["./*"]

+ 1 - 2
packages/ui/tsconfig.build.json

@@ -6,9 +6,8 @@
     "declaration": true,
     "noResolve": false,
     "preserveConstEnums": true,
-    "sourceMap": true,
+    "sourceMap": false,
     "noEmit": false,
-    "inlineSources": true,
 
     "baseUrl": ".",
     "paths": {

+ 45 - 0
yarn.lock

@@ -1213,6 +1213,11 @@
     comment-json "^4.1.0"
     find-up "^4.1.0"
 
+"@juggle/resize-observer@^3.3.1":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
+  integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
+
 "@kaishuu0123/markdown-it-fence@^0.2.0":
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/@kaishuu0123/markdown-it-fence/-/markdown-it-fence-0.2.0.tgz#f46722bfce4ab7eb3e051def5090dcae1bd6e36b"
@@ -5273,6 +5278,11 @@ camelize@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
 
+can-use-dom@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/can-use-dom/-/can-use-dom-0.1.0.tgz#22cc4a34a0abc43950f42c6411024a3f6366b45a"
+  integrity sha1-IsxKNKCrxDlQ9CxkEQJKP2NmtFo=
+
 caniuse-api@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
@@ -6394,6 +6404,11 @@ core-js@^2.4.0:
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
   integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
 
+core-js@^3.0.1:
+  version "3.21.1"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94"
+  integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==
+
 core-js@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09"
@@ -12568,6 +12583,11 @@ lodash._reinterpolate@^3.0.0:
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
   integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
 
+lodash.debounce@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+  integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
 lodash.defaults@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
@@ -12677,6 +12697,11 @@ lodash.templatesettings@^4.0.0:
   dependencies:
     lodash._reinterpolate "^3.0.0"
 
+lodash.throttle@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
+  integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
+
 lodash.truncate@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
@@ -18455,6 +18480,26 @@ simple-swizzle@^0.2.2:
   dependencies:
     is-arrayish "^0.3.1"
 
+simplebar-react@^2.3.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/simplebar-react/-/simplebar-react-2.3.6.tgz#27945a80b221caae8f241155cdba1bb6c201ebff"
+  integrity sha512-Igm/MRdt+LQ8edTTzjRoaATfXPPMByuUsVvHQHrkX7SH4jmvL85VshtOVcXFrOBspv9vqQtnIrOq/j9VmRSNDQ==
+  dependencies:
+    prop-types "^15.6.1"
+    simplebar "^5.3.6"
+
+simplebar@^5.3.6:
+  version "5.3.6"
+  resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.3.6.tgz#d9383576a09a6fd09e19543f8e9906a3e87c3896"
+  integrity sha512-FJUMbV+hNDd/m+1/fvD41TXKd5mSdlI5zgBygkaQIV3SffNbcLhSbJT6ufTs8ZNRLJ6i+qc/KCFMqWmvlGWMhA==
+  dependencies:
+    "@juggle/resize-observer" "^3.3.1"
+    can-use-dom "^0.1.0"
+    core-js "^3.0.1"
+    lodash.debounce "^4.0.8"
+    lodash.memoize "^4.1.2"
+    lodash.throttle "^4.1.1"
+
 sisteransi@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c"