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

Merge pull request #9687 from weseek/feat/161307-show-selected-page-count

feat: Show selected page count
Yuki Takei 1 год назад
Родитель
Сommit
7ebba3b0a6

+ 1 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditPages.tsx

@@ -6,9 +6,9 @@ import type { IPageForItem } from '~/interfaces/page';
 import { usePageSelectModal } from '~/stores/modal';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
-import { SelectedPageList } from '../../Common/SelectedPageList';
 
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
+import { SelectedPageList } from './SelectedPageList';
 
 
 type Props = {

+ 0 - 1
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementEditShare.tsx

@@ -106,7 +106,6 @@ export const AiAssistantManagementEditShare = (props: Props): JSX.Element => {
             id="shareAssistantSwitch"
             className="form-check-input"
             checked={isShared}
-            defaultChecked={isShared}
             onChange={changeShareToggleHandler}
           />
           <Label className="form-check-label" for="shareAssistantSwitch">

+ 82 - 15
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementHome.tsx

@@ -1,13 +1,16 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useState, useMemo } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
   ModalHeader, ModalBody, ModalFooter, Input,
 } from 'reactstrap';
 
-import { AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
+import { AiAssistantShareScope, AiAssistantAccessScope } from '~/features/openai/interfaces/ai-assistant';
+import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores-universal/context';
 
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { useAiAssistantManagementModal, AiAssistantManagementModalPageMode } from '../../../stores/ai-assistant';
 
 import { ShareScopeWarningModal } from './ShareScopeWarningModal';
@@ -17,10 +20,14 @@ type Props = {
   name: string;
   description: string;
   instruction: string;
-  shareScope: AiAssistantShareScope
+  shareScope: AiAssistantShareScope,
+  accessScope: AiAssistantAccessScope,
+  selectedPages: SelectedPage[];
+  selectedUserGroupsForAccessScope: PopulatedGrantedGroup[],
+  selectedUserGroupsForShareScope: PopulatedGrantedGroup[],
   onNameChange: (value: string) => void;
   onDescriptionChange: (value: string) => void;
-  onCreateAiAssistant: () => Promise<void>
+  onUpsertAiAssistant: () => Promise<void>
 }
 
 export const AiAssistantManagementHome = (props: Props): JSX.Element => {
@@ -30,9 +37,13 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
     description,
     instruction,
     shareScope,
+    accessScope,
+    selectedPages,
+    selectedUserGroupsForAccessScope,
+    selectedUserGroupsForShareScope,
     onNameChange,
     onDescriptionChange,
-    onCreateAiAssistant,
+    onUpsertAiAssistant,
   } = props;
 
   const { t } = useTranslation();
@@ -41,6 +52,18 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
 
   const [isShareScopeWarningModalOpen, setIsShareScopeWarningModalOpen] = useState(false);
 
+  const canUpsert = name !== '' && selectedPages.length !== 0;
+
+  const totalSelectedPageCount = useMemo(() => {
+    return selectedPages.reduce((total, selectedPage) => {
+      const descendantCount = selectedPage.isIncludeSubPage
+        ? selectedPage.page.descendantCount ?? 0
+        : 0;
+      const pageCountWithDescendants = descendantCount + 1;
+      return total + pageCountWithDescendants;
+    }, 0);
+  }, [selectedPages]);
+
   const getShareScopeLabel = useCallback((shareScope: AiAssistantShareScope) => {
     const baseLabel = `modal_ai_assistant.share_scope.${shareScope}.label`;
     return shareScope === AiAssistantShareScope.OWNER
@@ -48,16 +71,45 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
       : t(baseLabel);
   }, [currentUser?.username, t]);
 
-  const createAiAssistantHandler = useCallback(async() => {
-    // TODO: Implement the logic to check if the assistant has a share scope that includes private pages
-    // task: https://redmine.weseek.co.jp/issues/161341
-    if (true) {
+  const upsertAiAssistantHandler = useCallback(async() => {
+    const shouldWarning = () => {
+      const isDifferentUserGroup = () => {
+        const selectedShareScopeUserGroupIds = selectedUserGroupsForShareScope.map(userGroup => userGroup.item._id);
+        const selectedAccessScopeUserGroupIds = selectedUserGroupsForAccessScope.map(userGroup => userGroup.item._id);
+        if (selectedShareScopeUserGroupIds.length !== selectedAccessScopeUserGroupIds.length) {
+          return false;
+        }
+        return selectedShareScopeUserGroupIds.every((val, index) => val === selectedAccessScopeUserGroupIds[index]);
+      };
+
+      const determinedShareScope = determineShareScope(shareScope, accessScope);
+
+      if (determinedShareScope === AiAssistantShareScope.PUBLIC_ONLY && accessScope !== AiAssistantAccessScope.PUBLIC_ONLY) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.OWNER && accessScope !== AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.OWNER) {
+        return true;
+      }
+
+      if (determinedShareScope === AiAssistantShareScope.GROUPS && accessScope === AiAssistantAccessScope.GROUPS && !isDifferentUserGroup()) {
+        return true;
+      }
+
+      return false;
+    };
+
+    if (shouldWarning()) {
       setIsShareScopeWarningModalOpen(true);
       return;
     }
 
-    await onCreateAiAssistant();
-  }, [onCreateAiAssistant]);
+    await onUpsertAiAssistant();
+  }, [accessScope, onUpsertAiAssistant, selectedUserGroupsForAccessScope, selectedUserGroupsForShareScope, shareScope]);
 
   return (
     <>
@@ -116,7 +168,7 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
             >
               <span className="fw-normal">{t('modal_ai_assistant.page_mode_title.pages')}</span>
               <div className="d-flex align-items-center text-secondary">
-                <span>3ページ</span>
+                <span>{`${totalSelectedPageCount} ページ`}</span>
                 <span className="material-symbols-outlined ms-2 align-middle">chevron_right</span>
               </div>
             </button>
@@ -138,15 +190,30 @@ export const AiAssistantManagementHome = (props: Props): JSX.Element => {
         </ModalBody>
 
         <ModalFooter>
-          <button type="button" className="btn btn-outline-secondary" onClick={closeAiAssistantManagementModal}>キャンセル</button>
-          <button type="button" className="btn btn-primary" onClick={createAiAssistantHandler}>{t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}</button>
+          <button
+            type="button"
+            className="btn btn-outline-secondary"
+            onClick={closeAiAssistantManagementModal}
+          >
+            キャンセル
+          </button>
+
+          <button
+            type="button"
+            disabled={!canUpsert}
+            className="btn btn-primary"
+            onClick={upsertAiAssistantHandler}
+          >
+            {t(shouldEdit ? 'アシスタントを更新する' : 'アシスタントを作成する')}
+          </button>
         </ModalFooter>
       </div>
 
       <ShareScopeWarningModal
         isOpen={isShareScopeWarningModalOpen}
+        selectedPages={selectedPages}
         closeModal={() => setIsShareScopeWarningModalOpen(false)}
-        onSubmit={onCreateAiAssistant}
+        onSubmit={onUpsertAiAssistant}
       />
     </>
   );

+ 45 - 13
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal.tsx

@@ -1,13 +1,18 @@
-import React, { useCallback, useState, useEffect } from 'react';
+import React, {
+  useCallback, useState, useEffect,
+} from 'react';
 
-import { type IGrantedGroup, isPopulated } from '@growi/core';
+import {
+  type IGrantedGroup, isPopulated,
+} from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { Modal, TabContent, TabPane } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { AiAssistantAccessScope, AiAssistantShareScope } from '~/features/openai/interfaces/ai-assistant';
-import type { IPageForItem } from '~/interfaces/page';
+import type { IPagePathWithDescendantCount, IPageForItem } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import { useSWRxPagePathsWithDescendantCount } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import type { SelectedPage } from '../../../../interfaces/selected-page';
@@ -39,28 +44,44 @@ const convertToPopulatedGrantedGroups = (selectedGroups: IGrantedGroup[]): Popul
   return populatedGrantedGroups;
 };
 
-// string[] -> SelectedPage[]
-const convertToSelectedPages = (pagePathPatterns: string[]): SelectedPage[] => {
+const convertToSelectedPages = (pagePathPatterns: string[], pagePathsWithDescendantCount: IPagePathWithDescendantCount[]): SelectedPage[] => {
   return pagePathPatterns.map((pagePathPattern) => {
     const isIncludeSubPage = pagePathPattern.endsWith('/*');
     const path = isIncludeSubPage ? pagePathPattern.slice(0, -2) : pagePathPattern;
+    const page = pagePathsWithDescendantCount.find(page => page.path === path);
     return {
-      page: { path },
+      page: page ?? { path },
       isIncludeSubPage,
     };
   });
 };
 
+const removeGlobPath = (pagePathPattens?: string[]): string[] => {
+  if (pagePathPattens == null) {
+    return [];
+  }
+  return pagePathPattens.map((pagePathPattern) => {
+    return pagePathPattern.endsWith('/*') ? pagePathPattern.slice(0, -2) : pagePathPattern;
+  });
+};
+
 const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // Hooks
   const { t } = useTranslation();
   const { mutate: mutateAiAssistants } = useSWRxAiAssistants();
   const { data: aiAssistantManagementModalData, close: closeAiAssistantManagementModal } = useAiAssistantManagementModal();
+  const { data: pagePathsWithDescendantCount } = useSWRxPagePathsWithDescendantCount(
+    removeGlobPath(aiAssistantManagementModalData?.aiAssistantData?.pagePathPatterns) ?? null,
+    undefined,
+    true,
+    true,
+  );
 
   const aiAssistant = aiAssistantManagementModalData?.aiAssistantData;
   const shouldEdit = aiAssistant != null;
   const pageMode = aiAssistantManagementModalData?.pageMode ?? AiAssistantManagementModalPageMode.HOME;
 
+
   // States
   const [name, setName] = useState<string>('');
   const [description, setDescription] = useState<string>('');
@@ -71,13 +92,13 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   const [selectedPages, setSelectedPages] = useState<SelectedPage[]>([]);
   const [instruction, setInstruction] = useState<string>(t('modal_ai_assistant.default_instruction'));
 
+
   // Effects
   useEffect(() => {
     if (shouldEdit) {
       setName(aiAssistant.name);
       setDescription(aiAssistant.description);
       setInstruction(aiAssistant.additionalInstruction);
-      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns));
       setSelectedShareScope(aiAssistant.shareScope);
       setSelectedAccessScope(aiAssistant.accessScope);
       setSelectedUserGroupsForShareScope(convertToPopulatedGrantedGroups(aiAssistant.grantedGroupsForShareScope ?? []));
@@ -86,6 +107,13 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   // eslint-disable-next-line max-len
   }, [aiAssistant?.accessScope, aiAssistant?.additionalInstruction, aiAssistant?.description, aiAssistant?.grantedGroupsForAccessScope, aiAssistant?.grantedGroupsForShareScope, aiAssistant?.name, aiAssistant?.pagePathPatterns, aiAssistant?.shareScope, shouldEdit]);
 
+  useEffect(() => {
+    if (shouldEdit && pagePathsWithDescendantCount != null) {
+      setSelectedPages(convertToSelectedPages(aiAssistant.pagePathPatterns, pagePathsWithDescendantCount));
+    }
+  }, [aiAssistant?.pagePathPatterns, pagePathsWithDescendantCount, shouldEdit]);
+
+
   /*
   *  For AiAssistantManagementHome methods
   */
@@ -97,7 +125,7 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
     setDescription(value);
   }, []);
 
-  const createAiAssistantHandler = useCallback(async() => {
+  const upsertAiAssistantHandler = useCallback(async() => {
     try {
       const pagePathPatterns = selectedPages
         .map(selectedPage => (selectedPage.isIncludeSubPage ? `${selectedPage.page.path}/*` : selectedPage.page.path))
@@ -181,14 +209,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
   *  For AiAssistantManagementEditPages methods
   */
   const selectPageHandler = useCallback((page: IPageForItem, isIncludeSubPage: boolean) => {
-    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page._id);
-    if (page._id != null && !selectedPageIds.includes(page._id)) {
+    const selectedPageIds = selectedPages.map(selectedPage => selectedPage.page.path);
+    if (page.path != null && !selectedPageIds.includes(page.path)) {
       setSelectedPages([...selectedPages, { page, isIncludeSubPage }]);
     }
   }, [selectedPages]);
 
-  const removePageHandler = useCallback((pageId: string) => {
-    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page._id !== pageId));
+  const removePageHandler = useCallback((pagePath: string) => {
+    setSelectedPages(selectedPages.filter(selectedPage => selectedPage.page.path !== pagePath));
   }, [selectedPages]);
 
 
@@ -212,10 +240,14 @@ const AiAssistantManagementModalSubstance = (): JSX.Element => {
             name={name}
             description={description}
             shareScope={selectedShareScope}
+            accessScope={selectedAccessScope}
             instruction={instruction}
+            selectedPages={selectedPages}
+            selectedUserGroupsForShareScope={selectedUserGroupsForShareScope}
+            selectedUserGroupsForAccessScope={selectedUserGroupsForAccessScope}
             onNameChange={changeNameHandler}
             onDescriptionChange={changeDescriptionHandler}
-            onCreateAiAssistant={createAiAssistantHandler}
+            onUpsertAiAssistant={upsertAiAssistantHandler}
           />
         </TabPane>
 

+ 5 - 5
apps/app/src/features/openai/client/components/Common/SelectedPageList.tsx → apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPageList.tsx

@@ -1,10 +1,10 @@
 import { memo } from 'react';
 
-import type { SelectedPage } from '../../../interfaces/selected-page';
+import type { SelectedPage } from '../../../../interfaces/selected-page';
 
 type SelectedPageListProps = {
   selectedPages: SelectedPage[];
-  onRemove?: (pageId?: string) => void;
+  onRemove?: (pagePath?: string) => void;
 };
 
 const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages, onRemove }: SelectedPageListProps) => {
@@ -16,7 +16,7 @@ const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages,
     <div className="mb-3">
       {selectedPages.map(({ page, isIncludeSubPage }) => (
         <div
-          key={page._id}
+          key={page.path}
           className="mb-2 d-flex justify-content-between align-items-center bg-light rounded py-2 px-3"
         >
           <div className="d-flex align-items-center overflow-hidden">
@@ -25,11 +25,11 @@ const SelectedPageListBase: React.FC<SelectedPageListProps> = ({ selectedPages,
               : <>{page.path}</>
             }
           </div>
-          {onRemove != null && page._id != null && page._id && (
+          {onRemove != null && page.path != null && (
             <button
               type="button"
               className="btn p-0 ms-3 text-secondary"
-              onClick={() => onRemove(page._id)}
+              onClick={() => onRemove(page.path)}
             >
               <span className="material-symbols-outlined fs-4">delete</span>
             </button>

+ 12 - 6
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx

@@ -4,8 +4,11 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import type { SelectedPage } from '../../../../interfaces/selected-page';
+
 type Props = {
   isOpen: boolean,
+  selectedPages: SelectedPage[],
   closeModal: () => void,
   onSubmit: () => Promise<void>,
 }
@@ -13,11 +16,12 @@ type Props = {
 export const ShareScopeWarningModal = (props: Props): JSX.Element => {
   const {
     isOpen,
+    selectedPages,
     closeModal,
     onSubmit,
   } = props;
 
-  const createAiAssistantHandler = useCallback(() => {
+  const upsertAiAssistantHandler = useCallback(() => {
     closeModal();
     onSubmit();
   }, [closeModal, onSubmit]);
@@ -38,10 +42,12 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         </p>
 
         <div className="mb-4">
-          <p className="mb-2 text-secondary">含まれる限定公開ページ</p>
-          <code>
-            /Project/GROWI/新機能/GROWI AI
-          </code>
+          <p className="mb-2 text-secondary">選択されているページパス</p>
+          {selectedPages.map(selectedPage => (
+            <code key={selectedPage.page.path}>
+              {selectedPage.page.path}
+            </code>
+          ))}
         </div>
 
         <p>
@@ -61,7 +67,7 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
         <button
           type="button"
           className="btn btn-warning"
-          onClick={createAiAssistantHandler}
+          onClick={upsertAiAssistantHandler}
         >
           理解して続行する
         </button>

+ 4 - 1
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/AiAssistantTree.tsx

@@ -9,6 +9,7 @@ import loggerFactory from '~/utils/logger';
 
 import type { AiAssistantAccessScope } from '../../../../interfaces/ai-assistant';
 import { AiAssistantShareScope, type AiAssistantHasId } from '../../../../interfaces/ai-assistant';
+import { determineShareScope } from '../../../../utils/determine-share-scope';
 import { deleteAiAssistant } from '../../../services/ai-assistant';
 import { deleteThread } from '../../../services/thread';
 import { useAiAssistantChatSidebar, useAiAssistantManagementModal } from '../../../stores/ai-assistant';
@@ -121,7 +122,7 @@ const ThreadItems: React.FC<ThreadItemsProps> = ({ aiAssistantData, onThreadClic
 *  AiAssistantItem
 */
 const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): string => {
-  const determinedSharedScope = shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+  const determinedSharedScope = determineShareScope(shareScope, accessScope);
   switch (determinedSharedScope) {
     case AiAssistantShareScope.OWNER:
       return 'lock';
@@ -129,6 +130,8 @@ const getShareScopeIcon = (shareScope: AiAssistantShareScope, accessScope: AiAss
       return 'account_tree';
     case AiAssistantShareScope.PUBLIC_ONLY:
       return 'group';
+    case AiAssistantShareScope.SAME_AS_ACCESS_SCOPE:
+      return '';
   }
 };
 

+ 6 - 0
apps/app/src/features/openai/utils/determine-share-scope.ts

@@ -0,0 +1,6 @@
+import type { AiAssistantAccessScope } from '../interfaces/ai-assistant';
+import { AiAssistantShareScope } from '../interfaces/ai-assistant';
+
+export const determineShareScope = (shareScope: AiAssistantShareScope, accessScope: AiAssistantAccessScope): AiAssistantShareScope => {
+  return shareScope === AiAssistantShareScope.SAME_AS_ACCESS_SCOPE ? accessScope : shareScope;
+};

+ 5 - 0
apps/app/src/interfaces/page.ts

@@ -76,3 +76,8 @@ export type IOptionsForCreate = {
   origin?: Origin
   wip?: boolean,
 };
+
+export type IPagePathWithDescendantCount = {
+  path: string,
+  descendantCount: number,
+};

+ 53 - 3
apps/app/src/server/models/page.ts

@@ -7,7 +7,7 @@ import {
   type IPage,
   GroupType, type HasObjectId,
 } from '@growi/core';
-import type { IPagePopulatedToShowRevision } from '@growi/core/dist/interfaces';
+import type { IPagePopulatedToShowRevision, IUserHasId } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { isTopPage, hasSlash } from '@growi/core/dist/utils/page-path-utils';
 import { addTrailingSlash, normalizePath } from '@growi/core/dist/utils/path-utils';
@@ -23,7 +23,7 @@ import uniqueValidator from 'mongoose-unique-validator';
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
-import type { IOptionsForCreate } from '~/interfaces/page';
+import type { IOptionsForCreate, IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 
 import loggerFactory from '../../utils/logger';
@@ -91,7 +91,9 @@ export interface PageModel extends Model<PageDocument> {
   findByPath(path: string, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument> | null>
   findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<HydratedDocument<PageDocument>[]>
-  countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
+  descendantCountByPaths(
+    paths: string[], user: IUserHasId, userGroups?, includeEmpty?: boolean, includeAnyoneWithTheLink?: boolean
+  ): Promise<IPagePathWithDescendantCount[]>
   findParentByPath(path: string | null): Promise<HydratedDocument<PageDocument> | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option: FindRecentUpdatedPagesOption, includeEmpty?: boolean): Promise<PaginatedPages>
@@ -670,6 +672,54 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.descendantCountByPaths = async function(
+    paths: string[],
+    user: IUserHasId,
+    userGroups = null,
+    includeEmpty = false,
+    includeAnyoneWithTheLink = false,
+): Promise<IPagePathWithDescendantCount[]> {
+  if (paths.length === 0) {
+    throw new Error('paths are required');
+  }
+
+  const baseQuery = this.find({ path: { $in: paths } });
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+
+  await queryBuilder.addViewerCondition(user, userGroups, includeAnyoneWithTheLink);
+
+  const conditions = queryBuilder.query._conditions;
+
+  const aggregationPipeline = [
+    {
+      $match: conditions,
+    },
+    {
+      $project: {
+        _id: 0,
+        path: 1,
+        descendantCount: 1,
+      },
+    },
+    {
+      $group: {
+        _id: '$path',
+        descendantCount: { $first: '$descendantCount' },
+      },
+    },
+    {
+      $project: {
+        _id: 0,
+        path: '$_id',
+        descendantCount: 1,
+      },
+    },
+  ];
+
+  const pages = await this.aggregate<IPagePathWithDescendantCount>(aggregationPipeline);
+  return pages;
+};
+
 schema.statics.countByPathAndViewer = async function(path: string | null, user, userGroups = null, includeEmpty = false): Promise<number> {
   if (path == null) {
     throw new Error('path is required.');

+ 76 - 0
apps/app/src/server/routes/apiv3/page/get-page-paths-with-descendant-count.ts

@@ -0,0 +1,76 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { query } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-pages-by-page-paths');
+
+type GetPagePathsWithDescendantCountFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqQuery = {
+  paths: string[],
+  userGroups?: string[],
+  isIncludeEmpty?: boolean,
+  includeAnyoneWithTheLink?: boolean,
+}
+
+interface Req extends Request<undefined, ApiV3Response, undefined, ReqQuery> {
+  user: IUserHasId,
+}
+export const getPagePathsWithDescendantCountFactory: GetPagePathsWithDescendantCountFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  const validator: ValidationChain[] = [
+    query('paths').isArray().withMessage('paths must be an array of strings'),
+    query('paths').custom((paths: string[]) => {
+      if (paths.length > 300) {
+        throw new Error('paths must be an array of strings with a maximum length of 300');
+      }
+      return true;
+    }),
+    query('paths.*') // each item of paths
+      .isString()
+      .withMessage('paths must be an array of strings'),
+
+    query('userGroups').optional().isArray().withMessage('userGroups must be an array of strings'),
+    query('userGroups.*') // each item of userGroups
+      .isMongoId()
+      .withMessage('userGroups must be an array of strings'),
+
+    query('isIncludeEmpty').optional().isBoolean().withMessage('isIncludeEmpty must be a boolean'),
+    query('isIncludeEmpty').toBoolean(),
+
+    query('includeAnyoneWithTheLink').optional().isBoolean().withMessage('includeAnyoneWithTheLink must be a boolean'),
+    query('includeAnyoneWithTheLink').toBoolean(),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const {
+        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
+      } = req.query;
+
+      try {
+        const pagePathsWithDescendantCount = await Page.descendantCountByPaths(paths, req.user, userGroups, isIncludeEmpty, includeAnyoneWithTheLink);
+        return res.apiv3({ pagePathsWithDescendantCount });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/page/index.ts

@@ -35,6 +35,7 @@ import type { ApiV3Response } from '../interfaces/apiv3-response';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { syncLatestRevisionBodyToYjsDraftHandlerFactory } from './sync-latest-revision-body-to-yjs-draft';
@@ -266,6 +267,8 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
   });
 
+  router.get('/page-paths-with-descendant-count', getPagePathsWithDescendantCountFactory(crowi));
+
   router.get('/exist', checkPageExistenceHandlersFactory(crowi));
 
   /**

+ 0 - 1
apps/app/src/stores/page-listing.tsx

@@ -29,7 +29,6 @@ export const useSWRxPagesByPath = (path?: Nullable<string>): SWRResponse<IPageHa
   );
 };
 
-
 type RecentApiResult = {
   pages: IPageHasId[],
   totalCount: number,

+ 15 - 0
apps/app/src/stores/page.tsx

@@ -18,6 +18,7 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import type { IPagePathWithDescendantCount } from '~/interfaces/page';
 import type { IRecordApplicableGrant, IResCurrentGrantData } from '~/interfaces/page-grant';
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
@@ -362,3 +363,17 @@ export const useIsRevisionOutdated = (): SWRResponse<boolean, Error> => {
     ([, remoteRevisionId, currentRevisionId]) => { return remoteRevisionId !== currentRevisionId },
   );
 };
+
+
+export const useSWRxPagePathsWithDescendantCount = (
+    paths?: string[], userGroups?: string[], isIncludeEmpty?: boolean, includeAnyoneWithTheLink?: boolean,
+): SWRResponse<IPagePathWithDescendantCount[], Error> => {
+  return useSWR(
+    (paths != null && paths.length !== 0) ? ['/page/page-paths-with-descendant-count', paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink] : null,
+    ([endpoint, paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink]) => apiv3Get(
+      endpoint, {
+        paths, userGroups, isIncludeEmpty, includeAnyoneWithTheLink,
+      },
+    ).then(result => result.data.pagePathsWithDescendantCount),
+  );
+};