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

Merge branch 'dev/5.0.x' into
imprv/87696-show-duplilcate-modal-when-clicking-page-item-control

kaori 4 лет назад
Родитель
Сommit
77ca5d8c02
30 измененных файлов с 460 добавлено и 252 удалено
  1. 2 0
      packages/app/src/client/base.jsx
  2. 9 0
      packages/app/src/client/services/page-operation.ts
  3. 2 2
      packages/app/src/components/BookmarkButtons.tsx
  4. 100 75
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  5. 61 12
      packages/app/src/components/DescendantsPageList.tsx
  6. 8 8
      packages/app/src/components/IdenticalPathPage.tsx
  7. 2 10
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  8. 21 19
      packages/app/src/components/Navbar/SubNavButtons.tsx
  9. 5 5
      packages/app/src/components/NotFoundPage.tsx
  10. 1 1
      packages/app/src/components/Page/PageManagement.jsx
  11. 6 5
      packages/app/src/components/PageList/PageList.tsx
  12. 16 12
      packages/app/src/components/PageList/PageListItemL.tsx
  13. 16 17
      packages/app/src/components/PageRenameModal.jsx
  14. 1 14
      packages/app/src/components/SearchPage.jsx
  15. 51 5
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  16. 32 4
      packages/app/src/components/SearchPage/SearchResultList.tsx
  17. 11 3
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  18. 3 1
      packages/app/src/interfaces/common.ts
  19. 41 14
      packages/app/src/interfaces/page.ts
  20. 4 4
      packages/app/src/interfaces/search.ts
  21. 5 5
      packages/app/src/server/routes/apiv3/page-listing.ts
  22. 10 21
      packages/app/src/server/routes/page.js
  23. 2 2
      packages/app/src/server/service/page.ts
  24. 1 1
      packages/app/src/server/views/layout-growi/identical-path-page.html
  25. 1 0
      packages/app/src/server/views/layout/layout.html
  26. 3 3
      packages/app/src/stores/page.tsx
  27. 43 0
      packages/app/src/stores/ui.tsx
  28. 2 2
      packages/app/src/styles/_subnav.scss
  29. 0 6
      packages/app/src/styles/atoms/_buttons.scss
  30. 1 1
      packages/app/src/styles/theme/_apply-colors.scss

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

@@ -9,6 +9,7 @@ import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
@@ -44,6 +45,7 @@ const componentMappings = {
   'page-create-modal': <PageCreateModal />,
   'page-delete-modal': <PageDeleteModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
+  'page-rename-modal': <PageRenameModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
 

+ 9 - 0
packages/app/src/client/services/page-operation.ts

@@ -1,3 +1,5 @@
+import urljoin from 'url-join';
+
 import { SubscriptionStatusType } from '~/interfaces/subscription';
 
 import { toastError } from '../util/apiNotification';
@@ -51,3 +53,10 @@ export const unbookmark = async(pageId: string): Promise<void> => {
     toastError(err);
   }
 };
+
+export const exportAsMarkdown = (pageId: string, revisionId: string, format: string): void => {
+  const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
+  url.searchParams.append('format', format);
+  url.searchParams.append('revisionId', revisionId);
+  window.location.href = url.href;
+};

+ 2 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -9,7 +9,7 @@ import UserPictureList from './User/UserPictureList';
 import { useIsGuestUser } from '~/stores/context';
 
 interface Props {
-  bookmarkCount: number
+  bookmarkCount?: number
   isBookmarked?: boolean
   bookmarkedUsers?: IUser[]
   hideTotalNumber?: boolean
@@ -58,7 +58,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
       { !hideTotalNumber && (
         <>
           <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
-            {bookmarkCount}
+            {bookmarkCount ?? 0}
           </button>
           { bookmarkedUsers != null && (
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">

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

@@ -9,23 +9,23 @@ import { useTranslation } from 'react-i18next';
 import loggerFactory from '~/utils/logger';
 
 import {
-  IPageInfo, IPageInfoCommon, isExistPageInfo,
+  IPageInfoAll, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useSWRxPageInfo } from '~/stores/page';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
-export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoCommon | IPageInfo };
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
 
 type CommonProps = {
-  pageInfo?: IPageInfoCommon | IPageInfo,
+  pageInfo?: IPageInfoAll,
   isEnableActions?: boolean,
-  hideBookmarkMenuItem?: boolean,
-  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void> | void,
+  showBookmarkMenuItem?: boolean,
+  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
   onClickDuplicateMenuItem?: () => Promise<void> | void,
-  onClickRenameMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string) => void,
+  onClickDeleteMenuItem?: (pageId: string) => void,
 
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
 }
@@ -33,13 +33,15 @@ type CommonProps = {
 
 type DropdownMenuProps = CommonProps & {
   pageId: string,
+  isLoading?: boolean,
 }
 
 const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
   const { t } = useTranslation('');
 
   const {
-    pageId, pageInfo, isEnableActions, hideBookmarkMenuItem,
+    pageId, isLoading,
+    pageInfo, isEnableActions, showBookmarkMenuItem,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem,
     additionalMenuItemRenderer: AdditionalMenuItems,
   } = props;
@@ -47,7 +49,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isExistPageInfo(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
       return;
     }
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
@@ -71,7 +73,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
-    if (!isExistPageInfo(pageInfo) || onClickDeleteMenuItem == null) {
+    if (pageInfo == null || onClickDeleteMenuItem == null) {
       return;
     }
     if (!pageInfo.isDeletable) {
@@ -81,62 +83,74 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     await onClickDeleteMenuItem(pageId);
   }, [onClickDeleteMenuItem, pageId, pageInfo]);
 
-  if (pageId == null || pageInfo == null) {
-    return <></>;
-  }
-
-  return (
-    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
-
-      { !isEnableActions && (
-        <DropdownItem>
-          <p>
-            {t('search_result.currently_not_implemented')}
-          </p>
-        </DropdownItem>
-      ) }
+  let contents = <></>;
 
-      {/* Bookmark */}
-      { !hideBookmarkMenuItem && isExistPageInfo(pageInfo) && isEnableActions && (
-        <DropdownItem onClick={bookmarkItemClickedHandler}>
-          <i className="fa fa-fw fa-bookmark-o"></i>
-          { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
-        </DropdownItem>
-      ) }
+  if (isLoading) {
+    contents = (
+      <div className="text-muted text-center my-2">
+        <i className="fa fa-spinner fa-pulse"></i>
+      </div>
+    );
+  }
+  else if (pageId != null && pageInfo != null) {
+    contents = (
+      <>
+        { !isEnableActions && (
+          <DropdownItem>
+            <p>
+              {t('search_result.currently_not_implemented')}
+            </p>
+          </DropdownItem>
+        ) }
 
-      {/* Duplicate */}
-      { isExistPageInfo(pageInfo) && isEnableActions && (
-        <DropdownItem onClick={duplicateItemClickedHandler}>
-          <i className="icon-fw icon-docs"></i>
-          {t('Duplicate')}
-        </DropdownItem>
-      ) }
+        {/* Bookmark */}
+        { showBookmarkMenuItem && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+          <DropdownItem onClick={bookmarkItemClickedHandler}>
+            <i className="fa fa-fw fa-bookmark-o"></i>
+            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
+          </DropdownItem>
+        ) }
 
-      {/* Move/Rename */}
-      { isEnableActions && pageInfo.isMovable && (
-        <DropdownItem onClick={renameItemClickedHandler}>
-          <i className="icon-fw  icon-action-redo"></i>
-          {t('Move/Rename')}
-        </DropdownItem>
-      ) }
+        {/* Duplicate */}
+        { isEnableActions && !pageInfo.isEmpty && (
+          <DropdownItem onClick={duplicateItemClickedHandler}>
+            <i className="icon-fw icon-docs"></i>
+            {t('Duplicate')}
+          </DropdownItem>
+        ) }
 
-      { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
-
-      {/* divider */}
-      {/* Delete */}
-      { isExistPageInfo(pageInfo) && isEnableActions && pageInfo.isMovable && (
-        <>
-          <DropdownItem divider />
-          <DropdownItem
-            className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
-            disabled={!pageInfo.isDeletable}
-            onClick={deleteItemClickedHandler}
-          >
-            <i className="icon-fw icon-trash"></i>
-            {t('Delete')}
+        {/* Move/Rename */}
+        { isEnableActions && pageInfo.isMovable && (
+          <DropdownItem onClick={renameItemClickedHandler}>
+            <i className="icon-fw  icon-action-redo"></i>
+            {t('Move/Rename')}
           </DropdownItem>
-        </>
-      )}
+        ) }
+
+        { AdditionalMenuItems && <AdditionalMenuItems pageInfo={pageInfo} /> }
+
+        {/* divider */}
+        {/* Delete */}
+        { isEnableActions && pageInfo.isMovable && !pageInfo.isEmpty && (
+          <>
+            <DropdownItem divider />
+            <DropdownItem
+              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              disabled={!pageInfo.isDeletable}
+              onClick={deleteItemClickedHandler}
+            >
+              <i className="icon-fw icon-trash"></i>
+              {t('Delete')}
+            </DropdownItem>
+          </>
+        )}
+      </>
+    );
+  }
+
+  return (
+    <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+      {contents}
     </DropdownMenu>
   );
 });
@@ -144,19 +158,23 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
 type PageItemControlSubstanceProps = CommonProps & {
   pageId: string,
-  fetchOnOpen?: boolean,
+  fetchOnInit?: boolean,
+  children?: React.ReactNode,
 }
 
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnOpen,
+    pageId, pageInfo: presetPageInfo, fetchOnInit,
+    children,
     onClickBookmarkMenuItem, onClickDuplicateMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
 
-  const shouldFetch = presetPageInfo == null && (!fetchOnOpen || isOpen);
+  const shouldFetch = fetchOnInit === true || (!isIPageInfoForOperation(presetPageInfo) && isOpen);
+  const shouldMutate = fetchOnInit === true || !isIPageInfoForOperation(presetPageInfo);
+
   const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
   // mutate after handle event
@@ -165,10 +183,12 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
       await onClickBookmarkMenuItem(_pageId, _newValue);
     }
 
-    if (shouldFetch) {
+    if (shouldMutate) {
       mutatePageInfo();
     }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
+  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldMutate]);
+
+  const isLoading = shouldFetch && fetchedPageInfo == null;
 
   const duplicateMenuItemClickHandler = useCallback(async() => {
     if (onClickDuplicateMenuItem == null) {
@@ -179,13 +199,17 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-      <DropdownToggle color="transparent" className="border-0 rounded grw-btn-page-management p-0">
-        <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
-      </DropdownToggle>
+
+      { children ?? (
+        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control">
+          <i className="icon-options text-muted"></i>
+        </DropdownToggle>
+      ) }
 
       <PageItemControlDropdownMenu
         {...props}
-        pageInfo={presetPageInfo ?? fetchedPageInfo}
+        isLoading={isLoading}
+        pageInfo={fetchedPageInfo ?? presetPageInfo}
         onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
         onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
       />
@@ -197,13 +221,13 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
 type PageItemControlProps = CommonProps & {
   pageId?: string,
-  path?: string,
+  children?: React.ReactNode,
 }
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
-  const { pageId, path } = props;
+  const { pageId } = props;
 
-  if (pageId == null || path == null) {
+  if (pageId == null) {
     return <></>;
   }
 
@@ -211,8 +235,9 @@ export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
 };
 
 
-type AsyncPageItemControlProps = CommonProps & {
+type AsyncPageItemControlProps = Omit<CommonProps, 'pageInfo'> & {
   pageId?: string,
+  children?: React.ReactNode,
 }
 
 export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Element => {
@@ -222,5 +247,5 @@ export const AsyncPageItemControl = (props: AsyncPageItemControlProps): JSX.Elem
     return <></>;
   }
 
-  return <PageItemControlSubstance pageId={pageId} fetchOnOpen {...props} />;
+  return <PageItemControlSubstance pageId={pageId} fetchOnInit {...props} />;
 };

+ 61 - 12
packages/app/src/components/DescendantsPageList.tsx

@@ -1,6 +1,11 @@
 import React, { useState } from 'react';
+import {
+  IPageHasId, IPageWithMeta,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useIsGuestUser } from '~/stores/context';
 
-import { useSWRxPageList } from '~/stores/page';
+import { useSWRxPageInfoForList, useSWRxPageList } from '~/stores/page';
 
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
@@ -9,12 +14,54 @@ type Props = {
   path: string,
 }
 
+
+const convertToIPageWithEmptyMeta = (page: IPageHasId): IPageWithMeta => {
+  return { pageData: page };
+};
+
 const DescendantsPageList = (props: Props): JSX.Element => {
   const { path } = props;
 
   const [activePage, setActivePage] = useState(1);
 
-  const { data, error } = useSWRxPageList(path, activePage);
+  const { data: isGuestUser } = useIsGuestUser();
+
+  const { data: pagingResult, error } = useSWRxPageList(path, activePage);
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIds);
+
+  let pagingResultWithMeta: IPagingResult<IPageWithMeta> | undefined;
+
+  // initial data
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    // convert without meta at first
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pages.map(page => convertToIPageWithEmptyMeta(page)),
+    };
+  }
+
+  // inject data for listing
+  if (pagingResult != null) {
+    const pages = pagingResult.items;
+
+    const pageWithMetas = pages.map((page) => {
+      const pageInfo = (idToPageInfo ?? {})[page._id];
+
+      return {
+        pageData: page,
+        pageMeta: pageInfo,
+      } as IPageWithMeta;
+    });
+
+    pagingResultWithMeta = {
+      ...pagingResult,
+      items: pageWithMetas,
+    };
+  }
 
   function setPageNumber(selectedPageNumber) {
     setActivePage(selectedPageNumber);
@@ -28,7 +75,7 @@ const DescendantsPageList = (props: Props): JSX.Element => {
     );
   }
 
-  if (data === undefined) {
+  if (pagingResult == null || pagingResultWithMeta == null) {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
@@ -40,15 +87,17 @@ const DescendantsPageList = (props: Props): JSX.Element => {
 
   return (
     <>
-      <PageList pages={data} />
-
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={setPageNumber}
-        totalItemsCount={data.totalCount}
-        pagingLimit={data.limit}
-        align="center"
-      />
+      <PageList pages={pagingResultWithMeta} isEnableActions={!isGuestUser} />
+
+      <div className="my-4">
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={setPageNumber}
+          totalItemsCount={pagingResult.totalCount}
+          pagingLimit={pagingResult.limit}
+          align="center"
+        />
+      </div>
     </>
   );
 };

+ 8 - 8
packages/app/src/components/IdenticalPathPage.tsx

@@ -9,7 +9,7 @@ import { useCurrentPagePath } from '~/stores/context';
 
 import { PageListItemL } from './PageList/PageListItemL';
 import { useSWRxPageInfoForList } from '~/stores/page';
-import { IPageInfoForList, IPageWithMeta } from '~/interfaces/page';
+import { IPageHasId, IPageWithMeta } from '~/interfaces/page';
 
 
 type IdenticalPathAlertProps = {
@@ -57,9 +57,9 @@ const jsonNull = 'null';
 const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPageProps) => {
 
   const identicalPageDocument = document.getElementById('identical-path-page');
-  const pageDataList = JSON.parse(identicalPageDocument?.getAttribute('data-identical-page-data-list') || jsonNull);
+  const pages = JSON.parse(identicalPageDocument?.getAttribute('data-identical-path-pages') || jsonNull) as IPageHasId[];
 
-  const pageIds = pageDataList.map(data => data.pageData._id) as string[];
+  const pageIds = pages.map(page => page._id) as string[];
 
   const { data: idToPageInfoMap } = useSWRxPageInfoForList(pageIds);
 
@@ -81,19 +81,19 @@ const IdenticalPathPage:FC<IdenticalPathPageProps> = (props: IdenticalPathPagePr
         <IdenticalPathAlert path={currentPath} />
 
         <div className="page-list">
-          <ul className="page-list-ul list-group-flush border px-3">
-            {pageDataList.map((data) => {
-              const pageId = data.pageData._id;
+          <ul className="page-list-ul list-group-flush">
+            {pages.map((page) => {
+              const pageId = page._id;
               const pageInfo = (idToPageInfoMap ?? {})[pageId];
 
               const pageWithMeta: IPageWithMeta = {
-                pageData: data.pageData,
+                pageData: page,
                 pageMeta: pageInfo,
               };
 
               return (
                 <PageListItemL
-                  key={data.pageData._id}
+                  key={pageId}
                   page={pageWithMeta}
                   isSelected={false}
                   isChecked={false}

+ 2 - 10
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -5,8 +5,6 @@ import { useTranslation } from 'react-i18next';
 
 import { DropdownItem } from 'reactstrap';
 
-import urljoin from 'url-join';
-
 import { withUnstatedContainers } from '../UnstatedUtils';
 import EditorContainer from '~/client/services/EditorContainer';
 import {
@@ -28,6 +26,7 @@ import { apiPost } from '~/client/util/apiv1-client';
 import { IPageHasId } from '~/interfaces/page';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import PresentationIcon from '../Icons/PresentationIcon';
+import { exportAsMarkdown } from '~/client/services/page-operation';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -40,13 +39,6 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
   const { pageId, revisionId } = props;
 
-  const exportPageHandler = useCallback(async(format: string): Promise<void> => {
-    const url = new URL(urljoin(window.location.origin, '_api/v3/page/export', pageId));
-    url.searchParams.append('format', format);
-    url.searchParams.append('revisionId', revisionId);
-    window.location.href = url.href;
-  }, [pageId, revisionId]);
-
   return (
     <>
       <DropdownItem divider />
@@ -58,7 +50,7 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       </DropdownItem>
 
       {/* Export markdown */}
-      <DropdownItem onClick={() => exportPageHandler('md')}>
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
         <i className="icon-fw icon-cloud-download"></i>
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>

+ 21 - 19
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 
-import { IPageInfo, isExistPageInfo } from '~/interfaces/page';
+import { IPageInfoAll, isIPageInfoForEntity, isIPageInfoForOperation } from '~/interfaces/page';
 
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
@@ -25,7 +25,7 @@ type CommonProps = {
 type SubNavButtonsSubstanceProps= CommonProps & {
   pageId: string,
   revisionId: string,
-  pageInfo: IPageInfo,
+  pageInfo: IPageInfoAll,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -39,8 +39,8 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
-  const likerIds = pageInfo.likerIds != null ? pageInfo.likerIds.slice(0, 15) : [];
-  const seenUserIds = pageInfo.seenUserIds != null ? pageInfo.seenUserIds.slice(0, 15) : [];
+  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
@@ -51,30 +51,42 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     if (isGuestUser == null || isGuestUser) {
       return;
     }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
 
     await toggleSubscribe(pageId, pageInfo.subscriptionStatus);
     mutatePageInfo();
-  }, [isGuestUser, mutatePageInfo, pageId, pageInfo.subscriptionStatus]);
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
       return;
     }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
 
     await toggleLike(pageId, pageInfo.isLiked);
     mutatePageInfo();
-  }, [isGuestUser, mutatePageInfo, pageId, pageInfo.isLiked]);
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const bookmarkClickHandler = useCallback(async() => {
     if (isGuestUser == null || isGuestUser) {
       return;
     }
+    if (!isIPageInfoForOperation(pageInfo)) {
+      return;
+    }
 
     await toggleBookmark(pageId, pageInfo.isBookmarked);
     mutatePageInfo();
     mutateBookmarkInfo();
-  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo.isBookmarked]);
+  }, [isGuestUser, mutateBookmarkInfo, mutatePageInfo, pageId, pageInfo]);
 
+  if (!isIPageInfoForOperation(pageInfo)) {
+    return <></>;
+  }
 
   const {
     sumOfLikers, isLiked, bookmarkCount, isBookmarked,
@@ -109,17 +121,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
-          hideBookmarkMenuItem
         />
-        // <PageManagement
-        //   pageId={pageId}
-        //   revisionId={revisionId}
-        //   path={path}
-        //   isCompactMode={isCompactMode}
-        //   isDeletable={isDeletable}
-        //   isAbleToDeleteCompletely={isAbleToDeleteCompletely}
-        // >
-        // </PageManagement>
       )}
     </div>
   );
@@ -135,11 +137,11 @@ export const SubNavButtons = (props: SubNavButtonsProps): JSX.Element => {
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null);
 
-  if (revisionId == null || pageInfo == null || error != null) {
+  if (revisionId == null || error != null) {
     return <></>;
   }
 
-  if (!isExistPageInfo(pageInfo)) {
+  if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 

+ 5 - 5
packages/app/src/components/NotFoundPage.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import PageListIcon from './Icons/PageListIcon';
@@ -14,11 +14,11 @@ const NotFoundPage = (): JSX.Element => {
 
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const DescendantsPageListForThisPage = (): JSX.Element => {
+  const DescendantsPageListForThisPage = useCallback((): JSX.Element => {
     return currentPagePath != null
       ? <DescendantsPageList path={currentPagePath} />
       : <></>;
-  };
+  }, [currentPagePath]);
 
   const navTabMapping = useMemo(() => {
     return {
@@ -35,12 +35,12 @@ const NotFoundPage = (): JSX.Element => {
         index: 1,
       },
     };
-  }, [t]);
+  }, [DescendantsPageListForThisPage, t]);
 
 
   return (
     <div className="d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
     </div>
   );
 };

+ 1 - 1
packages/app/src/components/Page/PageManagement.jsx

@@ -192,7 +192,7 @@ const LegacyPageManagemenet = (props) => {
       <>
         <button
           type="button"
-          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded grw-btn-page-management"
+          className="btn-link nav-link dropdown-toggle dropdown-toggle-no-caret border-0 rounded btn-page-item-control"
           data-toggle="dropdown"
         >
           <i className="text-muted icon-options"></i>

+ 6 - 5
packages/app/src/components/PageList/PageList.tsx

@@ -1,19 +1,20 @@
 import React from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { IPageHasId } from '~/interfaces/page';
+import { IPageWithMeta } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 
 import { PageListItemL } from './PageListItemL';
 
 
 type Props = {
-  pages: IPagingResult<IPageHasId>,
+  pages: IPagingResult<IPageWithMeta>,
+  isEnableActions?: boolean,
 }
 
 const PageList = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { pages } = props;
+  const { pages, isEnableActions } = props;
 
   if (pages == null) {
     return (
@@ -26,7 +27,7 @@ const PageList = (props: Props): JSX.Element => {
   }
 
   const pageList = pages.items.map(page => (
-    <PageListItemL key={page._id} page={{ pageData: page }} />
+    <PageListItemL key={page._id} page={page} isEnableActions={isEnableActions} />
   ));
 
   if (pageList.length === 0) {
@@ -39,7 +40,7 @@ const PageList = (props: Props): JSX.Element => {
 
   return (
     <div className="page-list">
-      <ul className="page-list-ul page-list-ul-flat">
+      <ul className="page-list-ul list-group-flush">
         {pageList}
       </ul>
     </div>

+ 16 - 12
packages/app/src/components/PageList/PageListItemL.tsx

@@ -1,17 +1,19 @@
-import React, { FC, memo, useCallback } from 'react';
+import React, { memo, useCallback } from 'react';
 
 import Clamp from 'react-multiline-clamp';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
-import { IPageInfoForList, IPageWithMeta, isIPageInfoForList } from '~/interfaces/page';
+import {
+  IPageInfoAll, IPageWithMeta, isIPageInfoForEntity, isIPageInfoForListing,
+} from '~/interfaces/page';
 import { IPageSearchMeta, isIPageSearchMeta } from '~/interfaces/search';
 
-import { AsyncPageItemControl } from '../Common/Dropdown/PageItemControl';
+import { PageItemControl } from '../Common/Dropdown/PageItemControl';
 
 type Props = {
-  page: IPageWithMeta | IPageWithMeta<IPageSearchMeta> | IPageWithMeta<IPageInfoForList>,
+  page: IPageWithMeta | IPageWithMeta<IPageInfoAll & IPageSearchMeta>,
   isSelected?: boolean, // is item selected(focused)
   isChecked?: boolean, // is checkbox of item checked
   isEnableActions?: boolean,
@@ -21,7 +23,7 @@ type Props = {
   onClickDeleteButton?: (pageId: string) => void,
 }
 
-export const PageListItemL: FC<Props> = memo((props:Props) => {
+export const PageListItemL = memo((props: Props): JSX.Element => {
   const {
     // todo: refactoring variable name to clear what changed
     page: { pageData, pageMeta }, isSelected, onClickItem, onClickCheckbox, isChecked, isEnableActions,
@@ -33,7 +35,7 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
   const pagePath: DevidedPagePath = new DevidedPagePath(pageData.path, true);
 
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
-  const revisionShortBody = isIPageInfoForList(pageMeta) ? pageMeta.revisionShortBody : null;
+  const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
   const pageTitle = (
     <PagePathLabel
@@ -103,7 +105,7 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
             <div className="d-flex align-items-center mb-2">
               {/* Picture */}
               <span className="mr-2 d-none d-md-block">
-                {/* <UserPicture user={pageData.lastUpdateUser} size="md" /> */}
+                <UserPicture user={pageData.lastUpdateUser} size="md" />
               </span>
               {/* page title */}
               <Clamp lines={1}>
@@ -113,14 +115,16 @@ export const PageListItemL: FC<Props> = memo((props:Props) => {
               </Clamp>
 
               {/* page meta */}
-              <div className="d-none d-md-flex py-0 px-1">
-                <PageListMeta page={pageData} bookmarkCount={pageMeta?.bookmarkCount} shouldSpaceOutIcon />
-              </div>
+              { isIPageInfoForEntity(pageMeta) && (
+                <div className="d-none d-md-flex py-0 px-1">
+                  <PageListMeta page={pageData} bookmarkCount={pageMeta.bookmarkCount} shouldSpaceOutIcon />
+                </div>
+              ) }
               {/* doropdown icon includes page control buttons */}
               <div className="item-control ml-auto">
-                {/* TODO: use PageItemControl with prefetched IPageInfo object */}
-                <AsyncPageItemControl
+                <PageItemControl
                   pageId={pageData._id}
+                  pageInfo={pageMeta}
                   onClickDeleteMenuItem={props.onClickDeleteButton}
                   isEnableActions={isEnableActions}
                 />

+ 16 - 17
packages/app/src/components/PageRenameModal.jsx

@@ -10,6 +10,7 @@ import {
 import { withTranslation } from 'react-i18next';
 
 import { debounce } from 'throttle-debounce';
+import { usePageRenameModalStatus, usePageRenameModalOpened } from '~/stores/ui';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 
@@ -24,12 +25,16 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 
 const PageRenameModal = (props) => {
   const {
-    t, appContainer, path, pageId, revisionId,
+    t, appContainer,
   } = props;
 
   const { crowi } = appContainer.config;
+  const { data: isOpened } = usePageRenameModalOpened();
+  const { data: pagesDataToRename, close: closeRenameModal } = usePageRenameModalStatus();
 
-  const [pageNameInput, setPageNameInput] = useState(path);
+  const { path, revisionId, pageId } = pagesDataToRename;
+
+  const [pageNameInput, setPageNameInput] = useState('');
 
   const [errs, setErrs] = useState(null);
 
@@ -70,10 +75,11 @@ const PageRenameModal = (props) => {
   }, [path, t]);
 
   useEffect(() => {
-    if (props.isOpen) {
+    if (isOpened) {
       updateSubordinatedList();
+      setPageNameInput(path);
     }
-  }, [props.isOpen, updateSubordinatedList]);
+  }, [isOpened, path, updateSubordinatedList]);
 
 
   const checkExistPaths = async(newParentPath) => {
@@ -90,14 +96,14 @@ const PageRenameModal = (props) => {
 
   // eslint-disable-next-line react-hooks/exhaustive-deps
   const checkExistPathsDebounce = useCallback(
-    debounce(1000, checkExistPaths), [],
+    debounce(1000, checkExistPaths), [path],
   );
 
   useEffect(() => {
-    if (pageNameInput !== path) {
+    if (pageId != null && pageNameInput !== path) {
       checkExistPathsDebounce(pageNameInput, subordinatedPages);
     }
-  }, [pageNameInput, subordinatedPages, path, checkExistPathsDebounce]);
+  }, [pageNameInput, subordinatedPages, pageId, path, checkExistPathsDebounce]);
 
   /**
    * change pageNameInput
@@ -137,8 +143,8 @@ const PageRenameModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} autoFocus={false}>
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} autoFocus={false}>
+      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       <ModalBody>
@@ -195,7 +201,7 @@ const PageRenameModal = (props) => {
               </label>
             </div>
           )}
-          {isRenameRecursively && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
+          {isRenameRecursively && path != null && <ComparePathsTable path={path} subordinatedPages={subordinatedPages} newPagePath={pageNameInput} />}
           {isRenameRecursively && existingPaths.length !== 0 && <DuplicatedPathsTable existingPaths={existingPaths} oldPagePath={pageNameInput} />}
         </div>
 
@@ -252,13 +258,6 @@ const PageRenameModalWrapper = withUnstatedContainers(PageRenameModal, [AppConta
 PageRenameModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
 };
 
 export default withTranslation()(PageRenameModalWrapper);

+ 1 - 14
packages/app/src/components/SearchPage.jsx

@@ -19,7 +19,6 @@ import SearchControl from './SearchPage/SearchControl';
 import { CheckboxType, SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import PageDeleteModal from './PageDeleteModal';
 import { useIsGuestUser } from '~/stores/context';
-import { apiv3Get } from '~/client/util/apiv3-client';
 
 export const specificPathNames = {
   user: '/user',
@@ -189,19 +188,6 @@ class SearchPage extends React.Component {
         order,
       });
 
-      // TODO: fetch with /page-listing/info
-      // https://redmine.weseek.co.jp/issues/87695
-      /*
-       * non-await asynchronous short body fetch
-       */
-      // const pageIds = res.data.map((page) => {
-      //   if (page.pageMeta?.elasticSearchResult != null && page.pageMeta?.elasticSearchResult?.snippet.length !== 0) {
-      //     return null;
-      //   }
-
-      //   return page.pageData._id;
-      // }).filter(id => id != null);
-
       this.changeURL(keyword);
       if (res.data.length > 0) {
         this.setState({
@@ -306,6 +292,7 @@ class SearchPage extends React.Component {
         appContainer={this.props.appContainer}
         searchingKeyword={this.state.searchingKeyword}
         focusedSearchResultData={this.state.focusedSearchResultData}
+        showPageControlDropdown={!this.props.isGuestUser}
       >
       </SearchResultContent>
     );

+ 51 - 5
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -1,40 +1,86 @@
 import React, { FC, useCallback } from 'react';
 
+import { DropdownItem } from 'reactstrap';
+import { useTranslation } from 'react-i18next';
+
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
 
+import { exportAsMarkdown } from '~/client/services/page-operation';
+
 import RevisionLoader from '../Page/RevisionLoader';
 import AppContainer from '../../client/services/AppContainer';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
+import { AdditionalMenuItemsRendererProps } from '../Common/Dropdown/PageItemControl';
+
+
+type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
+  pageId: string,
+  revisionId: string,
+}
+
+const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { pageId, revisionId } = props;
+
+  return (
+    <>
+      <DropdownItem divider />
+
+      {/* Export markdown */}
+      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
+        <i className="icon-fw icon-cloud-download"></i>
+        {t('export_bulk.export_page_markdown')}
+      </DropdownItem>
+    </>
+  );
+};
+
 
 type Props ={
   appContainer: AppContainer,
   searchingKeyword:string,
   focusedSearchResultData : IPageWithMeta<IPageSearchMeta>,
+  showPageControlDropdown?: boolean,
 }
 
-
 const SearchResultContent: FC<Props> = (props: Props) => {
-  const page = props.focusedSearchResultData?.pageData;
+  const {
+    appContainer,
+    focusedSearchResultData,
+    showPageControlDropdown,
+  } = props;
 
-  const growiRenderer = props.appContainer.getRenderer('searchresult');
+  const page = focusedSearchResultData?.pageData;
+
+  const growiRenderer = appContainer.getRenderer('searchresult');
 
   const ControlComponents = useCallback(() => {
     if (page == null) {
       return <></>;
     }
 
+    const revisionId = typeof page.revision === 'string'
+      ? page.revision
+      : page.revision._id;
+
     return (
       <>
         <div className="h-50 d-flex flex-column align-items-end justify-content-center">
-          <SubNavButtons pageId={page._id} />
+          <SubNavButtons
+            pageId={page._id}
+            revisionId={revisionId}
+            showPageControlDropdown={showPageControlDropdown}
+            additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+          />
         </div>
         <div className="h-50 d-flex flex-column align-items-end justify-content-center">
         </div>
       </>
     );
-  }, [page]);
+  }, [page, showPageControlDropdown]);
 
   // return if page is null
   if (page == null) return <></>;

+ 32 - 4
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -1,13 +1,14 @@
 import React, { FC } from 'react';
-import { IPageWithMeta } from '~/interfaces/page';
+import { IPageInfoForEntity, IPageWithMeta, isIPageInfoForListing } from '~/interfaces/page';
 import { IPageSearchMeta } from '~/interfaces/search';
+import { useSWRxPageInfoForList } from '~/stores/page';
 
 import { PageListItemL } from '../PageList/PageListItemL';
 import PaginationWrapper from '../PaginationWrapper';
 
 
 type Props = {
-  pages: IPageWithMeta<IPageSearchMeta>[],
+  pages: IPageWithMeta<IPageInfoForEntity & IPageSearchMeta>[],
   selectedPagesIdList: Set<string>
   isEnableActions: boolean,
   searchResultCount?: number,
@@ -23,13 +24,40 @@ type Props = {
 
 const SearchResultList: FC<Props> = (props:Props) => {
   const {
-    focusedSearchResultData, selectedPagesIdList, isEnableActions,
+    pages, focusedSearchResultData, selectedPagesIdList, isEnableActions,
   } = props;
 
+  const pageIdsWithNoSnippet = pages
+    .filter(page => (page.pageMeta?.elasticSearchResult?.snippet.length ?? 0) === 0)
+    .map(page => page.pageData._id);
+
+  const { data: idToPageInfo } = useSWRxPageInfoForList(pageIdsWithNoSnippet);
+
+  let injectedPage;
+  // inject data to list
+  if (idToPageInfo != null) {
+    injectedPage = pages.map((page) => {
+      const pageInfo = idToPageInfo[page.pageData._id];
+
+      if (!isIPageInfoForListing(pageInfo)) {
+        // return as is
+        return page;
+      }
+
+      return {
+        pageData: page.pageData,
+        pageMeta: {
+          ...page.pageMeta,
+          revisionShortBody: pageInfo.revisionShortBody,
+        },
+      };
+    });
+  }
+
   const focusedPageId = (focusedSearchResultData != null && focusedSearchResultData.pageData != null) ? focusedSearchResultData.pageData._id : '';
   return (
     <ul className="page-list-ul list-group list-group-flush">
-      {Array.isArray(props.pages) && props.pages.map((page) => {
+      { (injectedPage ?? pages).map((page) => {
         const isChecked = selectedPagesIdList.has(page.pageData._id);
 
         return (

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

@@ -1,9 +1,12 @@
 import React, {
   useCallback, useState, FC, useEffect,
 } from 'react';
-import nodePath from 'path';
+import { DropdownToggle } from 'reactstrap';
 import { useTranslation } from 'react-i18next';
+
 import { useDrag, useDrop } from 'react-dnd';
+
+import nodePath from 'path';
 import { toastWarning, toastError } from '~/client/util/apiNotification';
 
 import { useSWRxPageChildren } from '~/stores/page-listing';
@@ -277,14 +280,19 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           <AsyncPageItemControl
             pageId={page._id}
             isEnableActions={isEnableActions}
+            showBookmarkMenuItem
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
             onClickDeleteMenuItem={onClickDeleteButton}
             onClickRenameMenuItem={onClickRenameButton}
-          />
+          >
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0">
+              <i className="icon-options fa fa-rotate-90 text-muted p-1"></i>
+            </DropdownToggle>
+          </AsyncPageItemControl>
           <button
             type="button"
-            className="border-0 rounded grw-btn-page-management p-0"
+            className="border-0 rounded btn-page-item-control p-0"
             onClick={onClickPlusButton}
           >
             <i className="icon-plus text-muted d-block p-1" />

+ 3 - 1
packages/app/src/interfaces/common.ts

@@ -2,6 +2,8 @@
  * Common types and interfaces
  */
 
+import { HasObjectId } from './has-object-id';
+
 
 // Foreign key field
-export type Ref<T> = string | T;
+export type Ref<T> = string | T & HasObjectId;

+ 41 - 14
packages/app/src/interfaces/page.ts

@@ -36,37 +36,64 @@ export type IPageHasId = IPage & HasObjectId;
 
 export type IPageForItem = Partial<IPageHasId & {isTarget?: boolean}>;
 
-export type IPageInfoCommon = {
+export type IPageInfo = {
   isEmpty: boolean,
   isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
 }
 
-export type IPageInfo = IPageInfoCommon & {
-  bookmarkCount: number,
-  sumOfLikers: number,
-  likerIds: string[],
-  sumOfSeenUsers: number,
-  seenUserIds: string[],
+export type IPageInfoForEntity = IPageInfo & {
+  bookmarkCount?: number,
+  sumOfLikers?: number,
+  likerIds?: string[],
+  sumOfSeenUsers?: number,
+  seenUserIds?: string[],
+}
 
+export type IPageInfoForOperation = IPageInfoForEntity & {
   isBookmarked?: boolean,
   isLiked?: boolean,
   subscriptionStatus?: SubscriptionStatusType,
 }
 
-export type IPageInfoForList = IPageInfo & HasRevisionShortbody;
+export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
+
+export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
 
-export const isExistPageInfo = (pageInfo: IPageInfoCommon | undefined): pageInfo is IPageInfo => {
+export const isIPageInfoForEntity = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForEntity => {
   return pageInfo != null && !pageInfo.isEmpty;
 };
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
-export const isIPageInfoForList = (pageInfo: any): pageInfo is IPageInfoForList => {
-  return pageInfo != null && pageInfo.revisionShortBody != null;
+export const isIPageInfoForOperation = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForOperation => {
+  return pageInfo != null
+    && isIPageInfoForEntity(pageInfo)
+    && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
+};
+
+export const isIPageInfoForListing = (pageInfo: IPageInfoAll | undefined): pageInfo is IPageInfoForListing => {
+  return pageInfo != null
+    && isIPageInfoForEntity(pageInfo)
+    && 'revisionShortBody' in pageInfo;
 };
 
-export type IPageWithMeta<M = Record<string, unknown>> = {
+// export type IPageInfoTypeResolver<T extends IPageInfo> =
+//   T extends HasRevisionShortbody ? IPageInfoForListing :
+//   T extends { isBookmarked?: boolean } | { isLiked?: boolean } | { subscriptionStatus?: SubscriptionStatusType } ? IPageInfoForOperation :
+//   T extends { bookmarkCount: number } ? IPageInfoForEntity :
+//   T extends { isEmpty: number } ? IPageInfo :
+//   T;
+
+/**
+ * Union Distribution
+ * @param pageInfo
+ * @returns
+ */
+// export const resolvePageInfo = <T extends IPageInfo>(pageInfo: T | undefined): IPageInfoTypeResolver<T> => {
+//   return <IPageInfoTypeResolver<T>>pageInfo;
+// };
+
+export type IPageWithMeta<M = IPageInfoAll> = {
   pageData: IPageHasId,
-  pageMeta?: Partial<IPageInfo> & M,
+  pageMeta?: M,
 };

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

@@ -1,4 +1,4 @@
-import { IPageWithMeta } from './page';
+import { IPageInfoAll, IPageWithMeta } from './page';
 
 export enum CheckboxType {
   NONE_CHECKED = 'noneChecked',
@@ -7,6 +7,7 @@ export enum CheckboxType {
 }
 
 export type IPageSearchMeta = {
+  bookmarkCount?: number,
   elasticSearchResult?: {
     snippet: string;
     highlightedPath: string;
@@ -14,9 +15,8 @@ export type IPageSearchMeta = {
   };
 }
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
-export const isIPageSearchMeta = (meta: any): meta is IPageSearchMeta => {
-  return !!(meta as IPageSearchMeta)?.elasticSearchResult;
+export const isIPageSearchMeta = (meta: IPageInfoAll | (IPageInfoAll & IPageSearchMeta) | undefined): meta is IPageInfoAll & IPageSearchMeta => {
+  return meta != null && 'elasticSearchResult' in meta;
 };
 
 export type IFormattedSearchResult = {

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

@@ -8,7 +8,7 @@ import ErrorV3 from '../../models/vo/error-apiv3';
 import loggerFactory from '../../../utils/logger';
 import Crowi from '../../crowi';
 import { ApiV3Response } from './interfaces/apiv3-response';
-import { IPageInfoForList, IPageInfoCommon, isExistPageInfo } from '~/interfaces/page';
+import { IPageInfoAll, isIPageInfoForEntity, IPageInfoForListing } from '~/interfaces/page';
 import PageService from '../../service/page';
 
 const logger = loggerFactory('growi:routes:apiv3:page-tree');
@@ -114,20 +114,20 @@ export default (crowi: Crowi): Router => {
       const shortBodiesMap = await pageService.shortBodiesMapByPageIds(foundIds, req.user);
       const bookmarkCountMap = await Bookmark.getPageIdToCountMap(foundIds) as Record<string, number>;
 
-      const idToPageInfoMap: Record<string, IPageInfoCommon|IPageInfoForList> = {};
+      const idToPageInfoMap: Record<string, IPageInfoAll> = {};
 
       for (const page of pages) {
-        // construct IPageInfoForList
+        // construct isIPageInfoForListing
         const basicPageInfo = pageService.constructBasicPageInfo(page);
 
-        const pageInfo: IPageInfoCommon | IPageInfoForList = (!isExistPageInfo(basicPageInfo))
+        const pageInfo = (!isIPageInfoForEntity(basicPageInfo))
           ? basicPageInfo
           // create IPageInfoForList
           : {
             ...basicPageInfo,
             bookmarkCount: bookmarkCountMap[page._id],
             revisionShortBody: shortBodiesMap[page._id],
-          } as IPageInfoForList;
+          } as IPageInfoForListing;
 
         idToPageInfoMap[page._id] = pageInfo;
       }

+ 10 - 21
packages/app/src/server/routes/page.js

@@ -4,6 +4,7 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
+import { PageQueryBuilder } from '../models/obsolete-page';
 import UpdatePost from '../models/update-post';
 import { PageRedirectModel } from '../models/page-redirect';
 
@@ -283,21 +284,6 @@ module.exports = function(crowi, app) {
     renderVars.notFoundTargetPathOrId = pathOrId;
   }
 
-  async function addRenderVarsForIdenticalPage(renderVars, pages) {
-    const pageIds = pages.map(p => p._id);
-
-    const identicalPageDataList = await Promise.all(pages.map(async(page) => {
-      page._doc.seenUserCount = (page.seenUsers && page.seenUsers.length) || 0;
-      return {
-        pageData: page,
-        pageMeta: {
-        },
-      };
-    }));
-
-    renderVars.identicalPageDataList = identicalPageDataList;
-  }
-
   function replacePlaceholdersOfTemplate(template, req) {
     if (req.user == null) {
       return '';
@@ -613,18 +599,21 @@ module.exports = function(crowi, app) {
    * redirector
    */
   async function redirector(req, res, next, path) {
-    const pages = await Page.findByPathAndViewer(path, req.user, null, false, true);
-
     const { redirectFrom } = req.query;
 
-    if (pages.length >= 2) {
+    const builder = new PageQueryBuilder(Page.find({ path }));
+    await Page.addConditionToFilteringByViewerForList(builder, req.user);
 
-      const renderVars = {};
+    const pages = await builder.query.lean().clone().exec('find');
+
+    if (pages.length >= 2) {
 
-      await addRenderVarsForIdenticalPage(renderVars, pages);
+      // populate to list
+      builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+      const identicalPathPages = await builder.query.lean().exec('find');
 
       return res.render('layout-growi/identical-path-page', {
-        ...renderVars,
+        identicalPathPages,
         redirectFrom,
         path,
       });

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

@@ -14,7 +14,7 @@ import {
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import ActivityDefine from '../util/activityDefine';
 import {
-  IPage, IPageInfo, IPageInfoCommon,
+  IPage, IPageInfo, IPageInfoForEntity,
 } from '~/interfaces/page';
 import { PageRedirectModel } from '../models/page-redirect';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -1613,7 +1613,7 @@ class PageService {
     });
   }
 
-  constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfoCommon | IPageInfo {
+  constructBasicPageInfo(page: IPage, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
     if (page.isEmpty) {
       return {
         isEmpty: true,

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

@@ -18,7 +18,7 @@
       <div class="flex-grow-1 flex-basis-0 mw-0">
         <div
           id="identical-path-page"
-          data-identical-page-data-list="{{ identicalPageDataList|json }}"
+          data-identical-path-pages="{{ identicalPathPages|json }}"
         ></div>
       </div>
       <div id="page-context"></div>

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

@@ -106,6 +106,7 @@
 <div id="page-create-modal"></div>
 <div id="page-delete-modal"></div>
 <div id="page-duplicate-modal"></div>
+<div id="page-rename-modal"></div>
 {% include '../modal/shortcuts.html' %}
 
 {% block body_end %}

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

@@ -4,7 +4,7 @@ import useSWRImmutable from 'swr/immutable';
 import { apiv3Get } from '~/client/util/apiv3-client';
 
 import {
-  IPageInfo, IPageInfoCommon, IPageInfoForList, IPageHasId,
+  IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { apiGet } from '../client/util/apiv1-client';
@@ -59,14 +59,14 @@ export const useSWRTagsInfo = (pageId: string | null | undefined): SWRResponse<I
   }));
 };
 
-export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfoCommon | IPageInfo, Error> => {
+export const useSWRxPageInfo = (pageId: string | null | undefined): SWRResponse<IPageInfo | IPageInfoForOperation, Error> => {
   return useSWRImmutable(
     pageId != null ? ['/page/info', pageId] : null,
     (endpoint, pageId) => apiv3Get(endpoint, { pageId }).then(response => response.data),
   );
 };
 
-export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfoCommon|IPageInfoForList>, Error> => {
+export const useSWRxPageInfoForList = (pageIds: string[] | null | undefined): SWRResponse<Record<string, IPageInfo | IPageInfoForListing>, Error> => {
 
   const shouldFetch = pageIds != null && pageIds.length > 0;
 

+ 43 - 0
packages/app/src/stores/ui.tsx

@@ -372,6 +372,49 @@ export const usePageDuplicateModalOpened = (): SWRResponse<boolean, Error> => {
   );
 };
 
+// PageRenameModal
+export type IPageForPageRenameModal = {
+  pageId: string,
+  revisionId: string,
+  path: string
+}
+
+type RenameModalStatus = {
+  isOpened: boolean,
+  pageId?: string,
+  revisionId?: string
+  path?: string,
+}
+
+type RenameModalStatusUtils = {
+  open(pageId: string, revisionId: string, path: string): Promise<RenameModalStatus | undefined>
+  close(): Promise<RenameModalStatus | undefined>
+}
+
+export const usePageRenameModalStatus = (status?: RenameModalStatus): SWRResponse<RenameModalStatus, Error> & RenameModalStatusUtils => {
+  const initialData: RenameModalStatus = {
+    isOpened: false, pageId: '', revisionId: '', path: '',
+  };
+  const swrResponse = useStaticSWR<RenameModalStatus, Error>('renameModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (pageId: string, revisionId: string, path: string) => swrResponse.mutate({
+      isOpened: true, pageId, revisionId, path,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
+export const usePageRenameModalOpened = (): SWRResponse<boolean, Error> => {
+  const { data } = usePageRenameModalStatus();
+  return useSWRImmutable(
+    data != null ? ['isRenameModalOpened', data] : null,
+    () => {
+      return data != null ? data.isOpened : false;
+    },
+  );
+};
 
 export const useSelectedGrant = (initialData?: Nullable<number>): SWRResponse<Nullable<number>, Error> => {
   return useStaticSWR<Nullable<number>, Error>('grant', initialData);

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

@@ -72,7 +72,7 @@
     vertical-align: bottom;
   }
 
-  .grw-btn-page-management {
+  .btn-page-item-control {
     height: 40px;
     font-size: 16px;
   }
@@ -117,7 +117,7 @@
       padding: 4px;
       font-size: 16px;
     }
-    .grw-btn-page-management {
+    .btn-page-item-control {
       width: 32px;
       height: 32px;
       padding: 4px;

+ 0 - 6
packages/app/src/styles/atoms/_buttons.scss

@@ -105,12 +105,6 @@
   }
 }
 
-// Page Management Dropdown icon
-.grw-btn-page-management {
-  background-color: transparent;
-  transition: 0.3s;
-}
-
 // define disabled button w/o pointer-events, see _override-bootstrap.scss
 .btn.disabled,
 .btn[disabled],

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

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