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

Merge branch 'dev/5.0.x' into feat/77544-search-sorting

NEEDLEMAN3\tatsu пре 4 година
родитељ
комит
808d7ba37b
25 измењених фајлова са 293 додато и 277 уклоњено
  1. 17 3
      packages/app/src/client/services/ContextExtractor.tsx
  2. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  3. 7 6
      packages/app/src/components/SearchPage/SearchResultListItem.tsx
  4. 12 71
      packages/app/src/components/Sidebar.tsx
  5. 35 4
      packages/app/src/components/Sidebar/PageTree.tsx
  6. 30 8
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  7. 59 34
      packages/app/src/components/Sidebar/PageTree/ItemsTree.tsx
  8. 8 3
      packages/app/src/components/StickyStretchableScroller.jsx
  9. 6 1
      packages/app/src/interfaces/page-listing-results.ts
  10. 1 1
      packages/app/src/interfaces/search.ts
  11. 31 0
      packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts
  12. 1 1
      packages/app/src/server/models/obsolete-page.js
  13. 5 3
      packages/app/src/server/models/page.ts
  14. 2 2
      packages/app/src/server/models/user-ui-settings.ts
  15. 14 0
      packages/app/src/server/routes/apiv3/page-listing.ts
  16. 0 18
      packages/app/src/server/routes/apiv3/user-ui-settings.ts
  17. 16 15
      packages/app/src/server/routes/index.js
  18. 4 4
      packages/app/src/server/routes/page.js
  19. 1 0
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  20. 2 11
      packages/app/src/server/service/search.ts
  21. 6 0
      packages/app/src/server/views/layout/layout.html
  22. 4 4
      packages/app/src/stores/context.tsx
  23. 15 1
      packages/app/src/stores/page-listing.tsx
  24. 15 69
      packages/app/src/stores/ui.tsx
  25. 1 17
      packages/app/src/styles/_sidebar.scss

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

@@ -4,12 +4,14 @@ import { pagePathUtils } from '@growi/core';
 import {
 import {
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd, useIsAbleToDeleteCompletely,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useIsDeletable, useIsDeleted, useIsNotCreatable, useIsPageExist, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
-  usePageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
+  useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
 } from '../../stores/context';
 } from '../../stores/context';
 import {
 import {
-  useIsDeviceSmallerThanMd, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser,
+  useIsDeviceSmallerThanMd,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 
@@ -24,6 +26,11 @@ const ContextExtractorOnce: FC = () => {
    */
    */
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
   const currentUser = JSON.parse(document.getElementById('growi-current-user')?.textContent || jsonNull);
 
 
+  /*
+   * UserUISettings from DOM
+   */
+  const userUISettings: Partial<IUserUISettings> = JSON.parse(document.getElementById('growi-user-ui-settings')?.textContent || jsonNull);
+
   /*
   /*
    * Page Context from DOM
    * Page Context from DOM
    */
    */
@@ -61,6 +68,13 @@ const ContextExtractorOnce: FC = () => {
   // App
   // App
   useCurrentUser(currentUser);
   useCurrentUser(currentUser);
 
 
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
   // Page
   // Page
   useCreatedAt(createdAt);
   useCreatedAt(createdAt);
   useDeleteUsername(deleteUsername);
   useDeleteUsername(deleteUsername);
@@ -75,7 +89,7 @@ const ContextExtractorOnce: FC = () => {
   useIsTrashPage(isTrashPage);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useLastUpdateUsername(lastUpdateUsername);
-  usePageId(pageId);
+  useCurrentPageId(pageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   usePageUser(pageUser);
   useCurrentPagePath(path);
   useCurrentPagePath(path);

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

@@ -7,7 +7,7 @@ import { IPageHasId } from '~/interfaces/page';
 
 
 type PageItemControlProps = {
 type PageItemControlProps = {
   page: Partial<IPageHasId>,
   page: Partial<IPageHasId>,
-  onClickDeleteButton?: (pageId: string)=>void,
+  onClickDeleteButton?: (pageId: string) => void,
 }
 }
 
 
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {
 const PageItemControl: FC<PageItemControlProps> = (props: PageItemControlProps) => {

+ 7 - 6
packages/app/src/components/SearchPage/SearchResultListItem.tsx

@@ -81,12 +81,13 @@ const SearchResultListItem: FC<Props> = (props:Props) => {
               </div>
               </div>
             </div>
             </div>
             <div className="my-2">
             <div className="my-2">
-              <Clamp
-                lines={2}
-              >
-                {pageMeta.elasticSearchResult != null
-                && <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>}
-              </Clamp>
+              {
+                pageMeta.elasticSearchResult != null && (
+                  <Clamp lines={2}>
+                    <div className="mt-1" dangerouslySetInnerHTML={{ __html: pageMeta.elasticSearchResult.snippet }}></div>
+                  </Clamp>
+                )
+              }
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 12 - 71
packages/app/src/components/Sidebar.tsx

@@ -43,20 +43,9 @@ const GlobalNavigation = () => {
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
 };
 };
 
 
-// dummy skelton contents
-const GlobalNavigationSkelton = () => {
-  return (
-    <div className="grw-sidebar-nav">
-      <div className="grw-sidebar-nav-primary-container">
-      </div>
-      <div className="grw-sidebar-nav-secondary-container">
-      </div>
-    </div>
-  );
-};
-
-
 const SidebarContentsWrapper = () => {
 const SidebarContentsWrapper = () => {
+  const [resetKey, setResetKey] = useState(0);
+
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
   const scrollTargetSelector = '#grw-sidebar-contents-scroll-target';
 
 
   const calcViewHeight = useCallback(() => {
   const calcViewHeight = useCallback(() => {
@@ -73,10 +62,11 @@ const SidebarContentsWrapper = () => {
         contentsElemSelector="#grw-sidebar-content-container"
         contentsElemSelector="#grw-sidebar-content-container"
         stickyElemSelector=".grw-sidebar"
         stickyElemSelector=".grw-sidebar"
         calcViewHeightFunc={calcViewHeight}
         calcViewHeightFunc={calcViewHeight}
+        resetKey={resetKey}
       />
       />
 
 
       <div id="grw-sidebar-contents-scroll-target">
       <div id="grw-sidebar-contents-scroll-target">
-        <div id="grw-sidebar-content-container">
+        <div id="grw-sidebar-content-container" onLoad={() => setResetKey(Math.random())}>
           <SidebarContents />
           <SidebarContents />
         </div>
         </div>
       </div>
       </div>
@@ -86,13 +76,6 @@ const SidebarContentsWrapper = () => {
   );
   );
 };
 };
 
 
-// dummy skelton contents
-const SidebarSkeltonContents = () => {
-  return (
-    <div>Skelton Contents!!!</div>
-  );
-};
-
 
 
 type Props = {
 type Props = {
 }
 }
@@ -104,26 +87,12 @@ const Sidebar: FC<Props> = (props: Props) => {
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
   const { data: isResizeDisabled, mutate: mutateSidebarResizeDisabled } = useSidebarResizeDisabled();
 
 
+  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
+
   const [isHover, setHover] = useState(false);
   const [isHover, setHover] = useState(false);
   const [isDragging, setDrag] = useState(false);
   const [isDragging, setDrag] = useState(false);
-  const [isMounted, setMounted] = useState(false);
 
 
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
   const isResizableByDrag = !isResizeDisabled && !isDrawerMode && (!isCollapsed || isHover);
-  /**
-   * hack and override UIController.storeState
-   *
-   * Since UIController is an unstated container, setState() in storeState method should be awaited before writing to cache.
-   */
-  // hackUIController() {
-  //   const { navigationUIController } = this.props;
-
-  //   // see: @atlaskit/navigation-next/dist/esm/ui-controller/UIController.js
-  //   const orgStoreState = navigationUIController.storeState;
-  //   navigationUIController.storeState = async(state) => {
-  //     await navigationUIController.setState(state);
-  //     orgStoreState(state);
-  //   };
-  // }
 
 
   const toggleDrawerMode = useCallback((bool) => {
   const toggleDrawerMode = useCallback((bool) => {
     const isStateModified = isResizeDisabled !== bool;
     const isStateModified = isResizeDisabled !== bool;
@@ -133,52 +102,24 @@ const Sidebar: FC<Props> = (props: Props) => {
 
 
     // Drawer <-- Dock
     // Drawer <-- Dock
     if (bool) {
     if (bool) {
-      // // cache state
-      // this.sidebarCollapsedCached = navigationUIController.state.isCollapsed;
-      // this.sidebarWidthCached = navigationUIController.state.productNavWidth;
-
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-drawer');
-      // }
-
       // disable resize
       // disable resize
       mutateSidebarResizeDisabled(true, false);
       mutateSidebarResizeDisabled(true, false);
     }
     }
     // Drawer --> Dock
     // Drawer --> Dock
     else {
     else {
-      // // clear transition temporary
-      // if (this.sidebarCollapsedCached) {
-      //   this.addCssClassTemporary('grw-sidebar-supress-transitions-to-dock');
-      // }
-
       // enable resize
       // enable resize
       mutateSidebarResizeDisabled(false, false);
       mutateSidebarResizeDisabled(false, false);
-
-      // // restore width
-      // if (this.sidebarWidthCached != null) {
-      //   navigationUIController.setState({ productNavWidth: this.sidebarWidthCached });
-      // }
     }
     }
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
   }, [isResizeDisabled, mutateSidebarResizeDisabled]);
 
 
-  // addCssClassTemporary(className) {
-  //   // clear
-  //   this.sidebarElem.classList.add(className);
-
-  //   // restore after 300ms
-  //   setTimeout(() => {
-  //     this.sidebarElem.classList.remove(className);
-  //   }, 300);
-  // }
-
   const backdropClickedHandler = useCallback(() => {
   const backdropClickedHandler = useCallback(() => {
     mutateDrawerOpened(false, false);
     mutateDrawerOpened(false, false);
   }, [mutateDrawerOpened]);
   }, [mutateDrawerOpened]);
 
 
   useEffect(() => {
   useEffect(() => {
-    // this.hackUIController();
-    setMounted(true);
+    setTimeout(() => {
+      setTransitionEnabled(true);
+    }, 1000);
   }, []);
   }, []);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -285,10 +226,10 @@ const Sidebar: FC<Props> = (props: Props) => {
     <>
     <>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
       <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
         <div className="data-layout-container">
         <div className="data-layout-container">
-          <div className="navigation" onMouseLeave={hoverOutHandler}>
+          <div className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`} onMouseLeave={hoverOutHandler}>
             <div className="grw-navigation-wrap">
             <div className="grw-navigation-wrap">
               <div className="grw-global-navigation">
               <div className="grw-global-navigation">
-                { isMounted ? <GlobalNavigation></GlobalNavigation> : <GlobalNavigationSkelton></GlobalNavigationSkelton> }
+                <GlobalNavigation></GlobalNavigation>
               </div>
               </div>
               <div
               <div
                 ref={resizableContainer}
                 ref={resizableContainer}
@@ -298,7 +239,7 @@ const Sidebar: FC<Props> = (props: Props) => {
               >
               >
                 <div className="grw-contextual-navigation-child">
                 <div className="grw-contextual-navigation-child">
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
                   <div role="group" className={`grw-contextual-navigation-sub ${!isHover && isCollapsed ? 'collapsed' : ''}`}>
-                    { isMounted ? <SidebarContentsWrapper></SidebarContentsWrapper> : <SidebarSkeltonContents></SidebarSkeltonContents> }
+                    <SidebarContentsWrapper></SidebarContentsWrapper>
                   </div>
                   </div>
                 </div>
                 </div>
               </div>
               </div>

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

@@ -1,16 +1,37 @@
-import React, { FC, memo } from 'react';
+import React, { FC, memo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import { useCurrentPagePath, useCurrentPageId, useTargetAndAncestors } from '~/stores/context';
 
 
 import ItemsTree from './PageTree/ItemsTree';
 import ItemsTree from './PageTree/ItemsTree';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
 import PrivateLegacyPages from './PageTree/PrivateLegacyPages';
+import { IPageForPageDeleteModal } from '../PageDeleteModal';
 
 
 
 
 const PageTree: FC = memo(() => {
 const PageTree: FC = memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const { data } = useSWRxV5MigrationStatus();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: targetId } = useCurrentPageId();
+  const { data: targetAndAncestorsData } = useTargetAndAncestors();
+
+  const { data: migrationStatus } = useSWRxV5MigrationStatus();
+
+  // for delete modal
+  const [isDeleteModalOpen, setDeleteModalOpen] = useState(false);
+  const [pagesToDelete, setPagesToDelete] = useState<IPageForPageDeleteModal[]>([]);
+
+  const onClickDeleteByPage = (page: IPageForPageDeleteModal) => {
+    setDeleteModalOpen(true);
+    setPagesToDelete([page]);
+  };
+
+  const onCloseDelete = () => {
+    setDeleteModalOpen(false);
+  };
+
+  const path = currentPath || '/';
 
 
   return (
   return (
     <>
     <>
@@ -19,12 +40,22 @@ const PageTree: FC = memo(() => {
       </div>
       </div>
 
 
       <div className="grw-sidebar-content-body">
       <div className="grw-sidebar-content-body">
-        <ItemsTree />
+        <ItemsTree
+          targetPath={path}
+          targetId={targetId}
+          targetAndAncestorsData={targetAndAncestorsData}
+          isDeleteModalOpen={isDeleteModalOpen}
+          pagesToDelete={pagesToDelete}
+          isAbleToDeleteCompletely={false} // TODO: pass isAbleToDeleteCompletely
+          isDeleteCompletelyModal={false} // TODO: pass isDeleteCompletelyModal
+          onCloseDelete={onCloseDelete}
+          onClickDeleteByPage={onClickDeleteByPage}
+        />
       </div>
       </div>
 
 
       <div className="grw-sidebar-content-footer">
       <div className="grw-sidebar-content-footer">
         {
         {
-          data?.migratablePagesCount != null && data.migratablePagesCount !== 0 && (
+          migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
             <PrivateLegacyPages />
             <PrivateLegacyPages />
           )
           )
         }
         }

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

@@ -7,26 +7,30 @@ import { useTranslation } from 'react-i18next';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
 import { useSWRxPageChildren } from '../../../stores/page-listing';
-import { usePageId } from '../../../stores/context';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import PageItemControl from '../../Common/Dropdown/PageItemControl';
 import PageItemControl from '../../Common/Dropdown/PageItemControl';
+import { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 
 
 interface ItemProps {
 interface ItemProps {
   itemNode: ItemNode
   itemNode: ItemNode
+  targetId?: string
   isOpen?: boolean
   isOpen?: boolean
+  onClickDeleteByPage?(page: IPageForPageDeleteModal): void
 }
 }
 
 
 // Utility to mark target
 // Utility to mark target
-const markTarget = (children: ItemNode[], targetId: string): void => {
+const markTarget = (children: ItemNode[], targetId?: string): void => {
+  if (targetId == null) {
+    return;
+  }
+
   children.forEach((node) => {
   children.forEach((node) => {
     if (node.page._id === targetId) {
     if (node.page._id === targetId) {
       node.page.isTarget = true;
       node.page.isTarget = true;
     }
     }
     return node;
     return node;
   });
   });
-
-  return;
 };
 };
 
 
 type ItemControlProps = {
 type ItemControlProps = {
@@ -82,7 +86,9 @@ const ItemCount: FC = () => {
 
 
 const Item: FC<ItemProps> = (props: ItemProps) => {
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { itemNode, isOpen: _isOpen = false } = props;
+  const {
+    itemNode, targetId, isOpen: _isOpen = false, onClickDeleteByPage,
+  } = props;
 
 
   const { page, children } = itemNode;
   const { page, children } = itemNode;
 
 
@@ -91,7 +97,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
 
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
   const [isNewPageInputShown, setNewPageInputShown] = useState(false);
 
 
-  const { data: targetId } = usePageId();
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
   const { data, error } = useSWRxPageChildren(isOpen ? page._id : null);
 
 
   const hasChildren = useCallback((): boolean => {
   const hasChildren = useCallback((): boolean => {
@@ -103,8 +108,24 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   }, [isOpen]);
   }, [isOpen]);
 
 
   const onClickDeleteButtonHandler = useCallback(() => {
   const onClickDeleteButtonHandler = useCallback(() => {
-    console.log('Show delete modal');
-  }, []);
+    if (onClickDeleteByPage == null) {
+      return;
+    }
+
+    const { _id: pageId, revision: revisionId, path } = page;
+
+    if (pageId == null || revisionId == null || path == null) {
+      throw Error('Any of _id, revision, and path must not be null.');
+    }
+
+    const pageToDelete: IPageForPageDeleteModal = {
+      pageId,
+      revisionId: revisionId as string,
+      path,
+    };
+
+    onClickDeleteByPage(pageToDelete);
+  }, [page, onClickDeleteByPage]);
 
 
   const inputValidator = (title: string | null): AlertInfo | null => {
   const inputValidator = (title: string | null): AlertInfo | null => {
     if (title == null || title === '') {
     if (title == null || title === '') {
@@ -192,6 +213,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             key={node.page._id}
             key={node.page._id}
             itemNode={node}
             itemNode={node}
             isOpen={false}
             isOpen={false}
+            onClickDeleteByPage={onClickDeleteByPage}
           />
           />
         ))
         ))
       }
       }

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

@@ -3,9 +3,10 @@ import React, { FC } from 'react';
 import { IPageHasId } from '../../../interfaces/page';
 import { IPageHasId } from '../../../interfaces/page';
 import { ItemNode } from './ItemNode';
 import { ItemNode } from './ItemNode';
 import Item from './Item';
 import Item from './Item';
-import { useSWRxPageAncestorsChildren } from '../../../stores/page-listing';
-import { useTargetAndAncestors, useCurrentPagePath } from '../../../stores/context';
-
+import { useSWRxPageAncestorsChildren, useSWRxRootPage } from '../../../stores/page-listing';
+import { TargetAndAncestors } from '~/interfaces/page-listing-results';
+import { toastError } from '~/client/util/apiNotification';
+import PageDeleteModal, { IPageForPageDeleteModal } from '~/components/PageDeleteModal';
 
 
 /*
 /*
  * Utility to generate initial node
  * Utility to generate initial node
@@ -41,53 +42,77 @@ const generateInitialNodeAfterResponse = (ancestorsChildren: Record<string, Part
   return rootNode;
   return rootNode;
 };
 };
 
 
+type ItemsTreeProps = {
+  targetPath: string
+  targetId?: string
+  targetAndAncestorsData?: TargetAndAncestors
+
+  // for deleteModal
+  isDeleteModalOpen: boolean
+  pagesToDelete: IPageForPageDeleteModal[]
+  isAbleToDeleteCompletely: boolean
+  isDeleteCompletelyModal: boolean
+  onCloseDelete(): void
+  onClickDeleteByPage(page: IPageForPageDeleteModal): void
+}
+
+const renderByInitialNode = (
+    initialNode: ItemNode, DeleteModal: JSX.Element, targetId?: string, onClickDeleteByPage?: (page: IPageForPageDeleteModal) => void,
+): JSX.Element => {
+  return (
+    <div className="grw-pagetree p-3">
+      <Item key={initialNode.page.path} targetId={targetId} itemNode={initialNode} isOpen onClickDeleteByPage={onClickDeleteByPage} />
+      {DeleteModal}
+    </div>
+  );
+};
+
 
 
 /*
 /*
  * ItemsTree
  * ItemsTree
  */
  */
-const ItemsTree: FC = () => {
-  const { data: currentPath } = useCurrentPagePath();
-
-  const { data, error } = useTargetAndAncestors();
-
-  const { data: ancestorsChildrenData, error: error2 } = useSWRxPageAncestorsChildren(currentPath || null);
-
-  if (error != null || error2 != null) {
-    return null;
-  }
+const ItemsTree: FC<ItemsTreeProps> = (props: ItemsTreeProps) => {
+  const {
+    targetPath, targetId, targetAndAncestorsData, isDeleteModalOpen, pagesToDelete, isAbleToDeleteCompletely, isDeleteCompletelyModal, onCloseDelete,
+    onClickDeleteByPage,
+  } = props;
+
+  const { data: ancestorsChildrenData, error: error1 } = useSWRxPageAncestorsChildren(targetPath);
+  const { data: rootPageData, error: error2 } = useSWRxRootPage();
+
+  const DeleteModal = (
+    <PageDeleteModal
+      isOpen={isDeleteModalOpen}
+      pages={pagesToDelete}
+      isAbleToDeleteCompletely={isAbleToDeleteCompletely}
+      isDeleteCompletelyModal={isDeleteCompletelyModal}
+      onClose={onCloseDelete}
+    />
+  );
 
 
-  if (data == null) {
+  if (error1 != null || error2 != null) {
+    // TODO: improve message
+    toastError('Error occurred while fetching pages to render PageTree');
     return null;
     return null;
   }
   }
 
 
-  const { targetAndAncestors, rootPage } = data;
-
-  let initialNode: ItemNode;
-
   /*
   /*
-   * Before swr response comes back
+   * Render completely
    */
    */
-  if (ancestorsChildrenData == null) {
-    initialNode = generateInitialNodeBeforeResponse(targetAndAncestors);
+  if (ancestorsChildrenData != null && rootPageData != null) {
+    const initialNode = generateInitialNodeAfterResponse(ancestorsChildrenData.ancestorsChildren, new ItemNode(rootPageData.rootPage));
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
   }
 
 
   /*
   /*
-   * When swr request finishes
+   * Before swr response comes back
    */
    */
-  else {
-    const { ancestorsChildren } = ancestorsChildrenData;
-
-    const rootNode = new ItemNode(rootPage);
-
-    initialNode = generateInitialNodeAfterResponse(ancestorsChildren, rootNode);
+  if (targetAndAncestorsData != null) {
+    const initialNode = generateInitialNodeBeforeResponse(targetAndAncestorsData.targetAndAncestors);
+    return renderByInitialNode(initialNode, DeleteModal, targetId, onClickDeleteByPage);
   }
   }
 
 
-  const isOpen = true;
-  return (
-    <div className="grw-pagetree p-3">
-      <Item key={(initialNode as ItemNode).page.path} itemNode={(initialNode as ItemNode)} isOpen={isOpen} />
-    </div>
-  );
+  return null;
 };
 };
 
 
 
 

+ 8 - 3
packages/app/src/components/StickyStretchableScroller.jsx

@@ -48,6 +48,7 @@ const StickyStretchableScroller = (props) => {
   const {
   const {
     children, contentsElemSelector, stickyElemSelector,
     children, contentsElemSelector, stickyElemSelector,
     calcViewHeightFunc, calcContentsHeightFunc,
     calcViewHeightFunc, calcContentsHeightFunc,
+    resetKey,
   } = props;
   } = props;
 
 
   if (scrollTargetSelector == null && children == null) {
   if (scrollTargetSelector == null && children == null) {
@@ -137,10 +138,12 @@ const StickyStretchableScroller = (props) => {
     };
     };
   }, [resetScrollbarDebounced]);
   }, [resetScrollbarDebounced]);
 
 
-  // setup effect by update props
+  // setup effect on init
   useEffect(() => {
   useEffect(() => {
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
+    if (resetKey != null) {
+      resetScrollbarDebounced();
+    }
+  }, [resetKey, resetScrollbarDebounced]);
 
 
   return (
   return (
     <>
     <>
@@ -156,6 +159,8 @@ StickyStretchableScroller.propTypes = {
   scrollTargetSelector: PropTypes.string,
   scrollTargetSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
   stickyElemSelector: PropTypes.string,
 
 
+  resetKey: PropTypes.any,
+
   calcViewHeightFunc: PropTypes.func,
   calcViewHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
   calcContentsHeightFunc: PropTypes.func,
 };
 };

+ 6 - 1
packages/app/src/interfaces/page-listing-results.ts

@@ -1,7 +1,12 @@
-import { IPageForItem } from './page';
+import { IPageForItem, IPageHasId } from './page';
 
 
 
 
 type ParentPath = string;
 type ParentPath = string;
+
+export interface RootPageResult {
+  rootPage: IPageHasId
+}
+
 export interface AncestorsChildrenResult {
 export interface AncestorsChildrenResult {
   ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
   ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>
 }
 }

+ 1 - 1
packages/app/src/interfaces/search.ts

@@ -9,7 +9,7 @@ export enum CheckboxType {
 export type IPageSearchResultData = {
 export type IPageSearchResultData = {
   pageData: IPageHasId,
   pageData: IPageHasId,
   pageMeta: {
   pageMeta: {
-    bookmarkCount: number,
+    bookmarkCount?: number,
     elasticSearchResult?: {
     elasticSearchResult?: {
       snippet: string,
       snippet: string,
       highlightedPath: string,
       highlightedPath: string,

+ 31 - 0
packages/app/src/server/middlewares/inject-user-ui-settings-to-localvars.ts

@@ -0,0 +1,31 @@
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import loggerFactory from '~/utils/logger';
+
+import UserUISettings from '../models/user-ui-settings';
+
+const logger = loggerFactory('growi:middleware:inject-user-ui-settings-to-localvars');
+
+async function getSettings(userId: string): Promise<Partial<IUserUISettings> | null> {
+  const doc = await UserUISettings.findOne({ user: userId }).exec();
+
+  let partialDoc: Partial<IUserUISettings> | null = null;
+  if (doc != null) {
+    partialDoc = doc.toObject();
+    delete partialDoc.user;
+  }
+
+  return partialDoc;
+}
+
+module.exports = () => {
+  return async(req, res, next) => {
+    try {
+      res.locals.userUISettings = await getSettings(req.user._id);
+    }
+    catch (err: unknown) {
+      logger.error(err);
+    }
+
+    next();
+  };
+};

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

@@ -256,7 +256,7 @@ export class PageQueryBuilder {
   }
   }
 
 
   addConditionToMinimizeDataForRendering() {
   addConditionToMinimizeDataForRendering() {
-    this.query = this.query.select('_id path isEmpty grant');
+    this.query = this.query.select('_id path isEmpty grant revision');
 
 
     return this;
     return this;
   }
   }

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

@@ -41,7 +41,7 @@ export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   createEmptyPagesByPaths(paths: string[]): Promise<void>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
   getParentIdAndFillAncestors(path: string): Promise<string | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean): Promise<PageDocument[]>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
@@ -227,7 +227,7 @@ const addViewerCondition = async(queryBuilder: PageQueryBuilder, user, userGroup
     relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
   }
   }
 
 
-  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, true);
+  queryBuilder.addConditionToFilteringByViewer(user, relatedUserGroups, false);
 };
 };
 
 
 /*
 /*
@@ -252,7 +252,7 @@ schema.statics.findByPathAndViewer = async function(
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result
  * The result will include the target as well
  * The result will include the target as well
  */
  */
-schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string): Promise<TargetAndAncestorsResult> {
+schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: string, user, userGroups): Promise<TargetAndAncestorsResult> {
   let path;
   let path;
   if (!hasSlash(pathOrId)) {
   if (!hasSlash(pathOrId)) {
     const _id = pathOrId;
     const _id = pathOrId;
@@ -270,6 +270,8 @@ schema.statics.findTargetAndAncestorsByPathOrId = async function(pathOrId: strin
 
 
   // Do not populate
   // Do not populate
   const queryBuilder = new PageQueryBuilder(this.find());
   const queryBuilder = new PageQueryBuilder(this.find());
+  await addViewerCondition(queryBuilder, user, userGroups);
+
   const _targetAndAncestors: PageDocument[] = await queryBuilder
   const _targetAndAncestors: PageDocument[] = await queryBuilder
     .addConditionAsMigrated()
     .addConditionAsMigrated()
     .addConditionToListByPathsArray(ancestorPaths)
     .addConditionToListByPathsArray(ancestorPaths)

+ 2 - 2
packages/app/src/server/models/user-ui-settings.ts

@@ -12,7 +12,7 @@ export interface UserUISettingsDocument extends IUserUISettings, Document {}
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 export type UserUISettingsModel = Model<UserUISettingsDocument>
 
 
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
 const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
-  user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+  user: { type: Schema.Types.ObjectId, ref: 'User', unique: true },
   isSidebarCollapsed: { type: Boolean, default: false },
   isSidebarCollapsed: { type: Boolean, default: false },
   currentSidebarContents: {
   currentSidebarContents: {
     type: String,
     type: String,
@@ -21,7 +21,7 @@ const schema = new Schema<UserUISettingsDocument, UserUISettingsModel>({
   },
   },
   currentProductNavWidth: { type: Number },
   currentProductNavWidth: { type: Number },
   preferDrawerModeByUser: { type: Boolean, default: false },
   preferDrawerModeByUser: { type: Boolean, default: false },
-  preferDrawerModeOnEditByUser: { type: Boolean, default: false },
+  preferDrawerModeOnEditByUser: { type: Boolean, default: true },
 });
 });
 
 
 
 

+ 14 - 0
packages/app/src/server/routes/apiv3/page-listing.ts

@@ -41,6 +41,20 @@ export default (crowi: Crowi): Router => {
   const router = express.Router();
   const router = express.Router();
 
 
 
 
+  router.get('/root', accessTokenParser, loginRequiredStrictly, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const Page: PageModel = crowi.model('Page');
+
+    let rootPage;
+    try {
+      rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('rootPage not found'));
+    }
+
+    return res.apiv3({ rootPage });
+  });
+
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
   router.get('/ancestors-children', accessTokenParser, loginRequiredStrictly, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
     const { path } = req.query;
     const { path } = req.query;

+ 0 - 18
packages/app/src/server/routes/apiv3/user-ui-settings.ts

@@ -25,24 +25,6 @@ module.exports = (crowi) => {
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
     body('settings.preferDrawerModeOnEditByUser').optional().isBoolean(),
   ];
   ];
 
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  router.get('/', loginRequiredStrictly, async(req: any, res: any) => {
-    const { user } = req;
-
-    try {
-      const updatedSettings = await UserUISettings.findOneAndUpdate(
-        { user: user._id },
-        { user: user._id },
-        { upsert: true, new: true },
-      );
-      return res.apiv3(updatedSettings);
-    }
-    catch (err) {
-      logger.error('Error', err);
-      return res.apiv3Err(new ErrorV3(err));
-    }
-  });
-
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
   router.put('/', loginRequiredStrictly, csrf, validatorForPut, apiV3FormValidator, async(req: any, res: any) => {
     const { user } = req;
     const { user } = req;

+ 16 - 15
packages/app/src/server/routes/index.js

@@ -28,6 +28,7 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
   const csrf = require('../middlewares/csrf')(crowi);
+  const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
 
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const form = require('../form');
   const form = require('../form');
@@ -52,7 +53,7 @@ module.exports = function(crowi, app) {
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
 
-  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+  app.get('/'                         , applicationInstalled, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
@@ -132,21 +133,21 @@ module.exports = function(crowi, app) {
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
   app.get('/admin/export/:fileName'             , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
 
-  app.get('/admin/*'                       , loginRequiredStrictly ,adminRequired, admin.notFound.index);
+  app.get('/admin/*'                            , loginRequiredStrictly ,adminRequired, admin.notFound.index);
 
 
-  app.get('/me'                       , loginRequiredStrictly , me.index);
+  app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
   // external-accounts
-  app.get('/me/external-accounts'                         , loginRequiredStrictly , me.externalAccounts.list);
+  app.get('/me/external-accounts'               , loginRequiredStrictly, injectUserUISettings, me.externalAccounts.list);
   // my drafts
   // my drafts
-  app.get('/me/drafts'                , loginRequiredStrictly, me.drafts.list);
+  app.get('/me/drafts'                          , loginRequiredStrictly, injectUserUISettings, me.drafts.list);
 
 
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/:id([0-9a-z]{24})' , certifySharedFile , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
   app.get('/attachment/profile/:id([0-9a-z]{24})' , loginRequired, attachment.api.get);
-  app.get('/attachment/:pageId/:fileName', loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  app.get('/download/:id([0-9a-z]{24})'    , loginRequired, attachment.api.download);
+  app.get('/attachment/:pageId/:fileName'       , loginRequired, attachment.api.obsoletedGetForMongoDB); // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  app.get('/download/:id([0-9a-z]{24})'         , loginRequired, attachment.api.download);
 
 
-  app.get('/_search'                 , loginRequired , search.searchPage);
-  app.get('/_api/search'             , accessTokenParser , loginRequired, search.api.search);
+  app.get('/_search'                            , loginRequired, injectUserUISettings, search.searchPage);
+  app.get('/_api/search'                        , accessTokenParser , loginRequired , search.api.search);
 
 
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/check_username'           , user.api.checkUsername);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
   app.get('/_api/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
@@ -177,9 +178,9 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.post('/_api/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , csrf, attachment.api.removeProfileImage);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
   app.get('/_api/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
 
-  app.get('/trash$'                   , loginRequired , page.trashPageShowWrapper);
-  app.get('/trash/$'                  , loginRequired , page.trashPageListShowWrapper);
-  app.get('/trash/*/$'                , loginRequired , page.deletedPageListShowWrapper);
+  app.get('/trash$'                   , loginRequired, injectUserUISettings, page.trashPageShowWrapper);
+  app.get('/trash/$'                  , loginRequired, injectUserUISettings, page.trashPageListShowWrapper);
+  app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
 
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
@@ -197,9 +198,9 @@ module.exports = function(crowi, app) {
 
 
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 
-  app.get('/:id([0-9a-z]{24})'       , loginRequired , page.showPage);
+  app.get('/:id([0-9a-z]{24})'       , loginRequired , injectUserUISettings, page.showPage);
 
 
-  app.get('/*/$'                   , loginRequired , page.redirectorWithEndOfSlash);
-  app.get('/*'                     , loginRequired , autoReconnectToSearch, page.redirector);
+  app.get('/*/$'                   , loginRequired , injectUserUISettings, page.redirectorWithEndOfSlash);
+  app.get('/*'                     , loginRequired , autoReconnectToSearch, injectUserUISettings, page.redirector);
 
 
 };
 };

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

@@ -264,8 +264,8 @@ module.exports = function(crowi, app) {
     renderVars.pages = result.pages;
     renderVars.pages = result.pages;
   }
   }
 
 
-  async function addRenderVarsForPageTree(renderVars, path) {
-    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path);
+  async function addRenderVarsForPageTree(renderVars, path, user) {
+    const { targetAndAncestors, rootPage } = await Page.findTargetAndAncestorsByPathOrId(path, user);
 
 
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
     if (targetAndAncestors.length === 0 && !isTopPage(path)) {
       throw new Error('Ancestors must have at least one page.');
       throw new Error('Ancestors must have at least one page.');
@@ -385,7 +385,7 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
     await addRenderVarsForDescendants(renderVars, portalPath, req.user, offset, limit);
 
 
-    await addRenderVarsForPageTree(renderVars, portalPath);
+    await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);
@@ -447,7 +447,7 @@ module.exports = function(crowi, app) {
       await addRenderVarsForUserPage(renderVars, page);
       await addRenderVarsForUserPage(renderVars, page);
     }
     }
 
 
-    await addRenderVarsForPageTree(renderVars, path);
+    await addRenderVarsForPageTree(renderVars, path, req.user);
 
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
     return res.render(view, renderVars);
     return res.render(view, renderVars);

+ 1 - 0
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -911,6 +911,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data> {
     this.appendSortOrder(query, sort, order);
     this.appendSortOrder(query, sort, order);
 
 
     await this.appendFunctionScore(query, queryString);
     await this.appendFunctionScore(query, queryString);
+    this.appendHighlight(query);
 
 
     return this.searchKeyword(query);
     return this.searchKeyword(query);
   }
   }

+ 2 - 11
packages/app/src/server/service/search.ts

@@ -13,7 +13,7 @@ import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { PageModel } from '../models/page';
 import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
-import { IPageHasId } from '~/interfaces/page';
+import { IPageSearchResultData } from '~/interfaces/search';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');
 const logger = loggerFactory('growi:service:search');
@@ -35,16 +35,7 @@ const normalizeQueryString = (_queryString: string): string => {
 };
 };
 
 
 export type FormattedSearchResult = {
 export type FormattedSearchResult = {
-  data: {
-    pageData: IPageHasId
-    pageMeta: {
-      bookmarkCount?: number
-      elasticsearchResult?: {
-        snippet: string
-        highlightedPath: string
-      }
-    }
-  }[]
+  data: IPageSearchResultData[]
 
 
   totalCount: number
   totalCount: number
 
 

+ 6 - 0
packages/app/src/server/views/layout/layout.html

@@ -120,6 +120,12 @@
   {{ user|json|safe|preventXss }}
   {{ user|json|safe|preventXss }}
   </script>
   </script>
 {% endif %}
 {% endif %}
+{% if userUISettings != null %}
+  <script type="application/json" id="growi-user-ui-settings">
+  {{ userUISettings|json|safe }}
+  </script>
+{% endif %}
+
 
 
 {% block custom_script %}
 {% block custom_script %}
 <script>
 <script>

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

@@ -19,13 +19,13 @@ export const useRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable
   return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
   return useStaticSWR<Nullable<any>, Error>('revisionId', initialData ?? null);
 };
 };
 
 
-export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('currentPagePath', initialData ?? null);
+export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData ?? null);
 };
 };
 
 
 
 
-export const usePageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('pageId', initialData ?? null);
+export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<any>, Error> => {
+  return useStaticSWR<Nullable<any>, Error>('currentPageId', initialData ?? null);
 };
 };
 
 
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useRevisionCreatedAt = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {

+ 15 - 1
packages/app/src/stores/page-listing.tsx

@@ -1,9 +1,23 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
 
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
-import { AncestorsChildrenResult, ChildrenResult, V5MigrationStatus } from '../interfaces/page-listing-results';
+import {
+  AncestorsChildrenResult, ChildrenResult, V5MigrationStatus, RootPageResult,
+} from '../interfaces/page-listing-results';
 
 
 
 
+export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
+  return useSWR(
+    '/page-listing/root',
+    endpoint => apiv3Get(endpoint).then((response) => {
+      return {
+        rootPage: response.data.rootPage,
+      };
+    }),
+    { revalidateOnFocus: false },
+  );
+};
+
 export const useSWRxPageAncestorsChildren = (
 export const useSWRxPageAncestorsChildren = (
     path: string | null,
     path: string | null,
 ): SWRResponse<AncestorsChildrenResult, Error> => {
 ): SWRResponse<AncestorsChildrenResult, Error> => {

+ 15 - 69
packages/app/src/stores/ui.tsx

@@ -1,17 +1,14 @@
 import useSWR, {
 import useSWR, {
-  useSWRConfig, SWRResponse, Key, Fetcher, Middleware,
+  useSWRConfig, SWRResponse, Key, Fetcher,
 } from 'swr';
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { sessionStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
-import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { useCurrentPagePath, useIsEditable } from './context';
 import { useCurrentPagePath, useIsEditable } from './context';
 
 
 const logger = loggerFactory('growi:stores:ui');
 const logger = loggerFactory('growi:stores:ui');
@@ -36,16 +33,6 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                      for switching UI
  *                      for switching UI
  *********************************************************** */
  *********************************************************** */
 
 
-export const useSWRxUserUISettings = (): SWRResponse<IUserUISettings, Error> => {
-  const key = isServer ? null : 'userUISettings';
-
-  return useSWRImmutable(
-    key,
-    () => apiv3Get<IUserUISettings>('/user-ui-settings').then(response => response.data),
-  );
-};
-
-
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
 export const useIsMobile = (): SWRResponse<boolean|null, Error> => {
   const key = isServer ? null : 'isMobile';
   const key = isServer ? null : 'isMobile';
 
 
@@ -167,20 +154,24 @@ export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean|null, Error> =>
   return useStaticSWR(key);
   return useStaticSWR(key);
 };
 };
 
 
-export const usePreferDrawerModeByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeByUser';
-  const initialData = data?.preferDrawerModeByUser;
+export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeByUser', initialData ?? null, { fallbackData: false });
+};
+
+export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('preferDrawerModeOnEditByUser', initialData ?? null, { fallbackData: true });
+};
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isSidebarCollapsed', initialData ?? null, { fallbackData: false });
 };
 };
 
 
-export const usePreferDrawerModeOnEditByUser = (isPrefered?: boolean): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key: Key = data === undefined ? null : 'preferDrawerModeOnEditByUser';
-  const initialData = data?.preferDrawerModeOnEditByUser;
+export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
+  return useStaticSWR('sidebarContents', initialData ?? null, { fallbackData: SidebarContentsType.RECENT });
+};
 
 
-  return useStaticSWR(key, isPrefered || null, { fallbackData: initialData, use: [sessionStorageMiddleware] });
+export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
+  return useStaticSWR('productNavWidth', initialData ?? null, { fallbackData: 320 });
 };
 };
 
 
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
 export const useDrawerMode = (): SWRResponse<boolean, Error> => {
@@ -215,51 +206,6 @@ export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error>
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
   return useStaticSWR('isDrawerOpened', isOpened || null, { fallbackData: initialData });
 };
 };
 
 
-export const useSidebarCollapsed = (): SWRResponse<boolean, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'isSidebarCollapsed';
-  const initialData = data?.isSidebarCollapsed || false;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentSidebarContents = (): SWRResponse<SidebarContentsType, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'sidebarContents';
-  const initialData = data?.currentSidebarContents || SidebarContentsType.RECENT;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
-export const useCurrentProductNavWidth = (): SWRResponse<number, Error> => {
-  const { data } = useSWRxUserUISettings();
-  const key = data === undefined ? null : 'productNavWidth';
-  const initialData = data?.currentProductNavWidth || 320;
-
-  return useStaticSWR(
-    key,
-    null,
-    {
-      fallbackData: initialData,
-      use: [sessionStorageMiddleware],
-    },
-  );
-};
-
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
 export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
   const initialData = false;
   const initialData = false;
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });
   return useStaticSWR('isSidebarResizeDisabled', isDisabled || null, { fallbackData: initialData });

+ 1 - 17
packages/app/src/styles/_sidebar.scss

@@ -267,7 +267,7 @@
     top: 0;
     top: 0;
     width: 0;
     width: 0;
   }
   }
-  div.navigation {
+  div.navigation.transition-enabled {
     max-width: 80vw;
     max-width: 80vw;
 
 
     // apply transition
     // apply transition
@@ -329,22 +329,6 @@
   }
   }
 }
 }
 
 
-// supress transition
-.grw-sidebar {
-  &.grw-sidebar-supress-transitions-to-drawer {
-    div.navigation {
-      transition: none !important;
-    }
-  }
-
-  &.grw-sidebar-supress-transitions-to-dock {
-    div.content,
-    div.contextual-navigation {
-      transition: none !important;
-    }
-  }
-}
-
 .grw-sidebar-backdrop.modal-backdrop {
 .grw-sidebar-backdrop.modal-backdrop {
   z-index: $zindex-fixed + 1;
   z-index: $zindex-fixed + 1;
 }
 }