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

Merge branch 'master' into fix/156800-csrf-protection-origin

yusa-a 6 месяцев назад
Родитель
Сommit
f02e3f4ae1
34 измененных файлов с 1573 добавлено и 385 удалено
  1. 1 1
      .mcp.json
  2. 186 0
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  3. 17 1
      CHANGELOG.md
  4. 2 4
      apps/app/package.json
  5. 1 1
      apps/app/src/client/util/apiv1-client.ts
  6. 1 1
      apps/app/src/client/util/apiv3-client.ts
  7. 3 11
      apps/app/src/interfaces/page-listing-results.ts
  8. 14 0
      apps/app/src/interfaces/page.ts
  9. 3 6
      apps/app/src/server/crowi/index.js
  10. 34 0
      apps/app/src/server/models/openapi/page-listing.ts
  11. 9 80
      apps/app/src/server/routes/apiv3/page-listing.ts
  12. 1 6
      apps/app/src/server/routes/apiv3/pages/index.js
  13. 1 0
      apps/app/src/server/service/page-listing/index.ts
  14. 407 0
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  15. 114 0
      apps/app/src/server/service/page-listing/page-listing.ts
  16. 15 2
      apps/app/src/server/service/page-operation.ts
  17. 3 105
      apps/app/src/server/service/page/index.ts
  18. 0 4
      apps/app/src/server/service/page/page-service.ts
  19. 0 1
      apps/pdf-converter/package.json
  20. 2 2
      apps/slackbot-proxy/package.json
  21. 4 1
      package.json
  22. 1 1
      packages/pdf-converter-client/package.json
  23. 8 3
      packages/remark-attachment-refs/package.json
  24. 2 0
      packages/remark-attachment-refs/src/@types/declaration.d.ts
  25. 298 0
      packages/remark-attachment-refs/src/client/stores/refs.spec.ts
  26. 4 3
      packages/remark-attachment-refs/src/server/routes/refs.ts
  27. 1 0
      packages/remark-attachment-refs/tsconfig.json
  28. 16 0
      packages/remark-attachment-refs/vitest.config.ts
  29. 3 2
      packages/remark-lsx/package.json
  30. 153 0
      packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts
  31. 2 1
      packages/remark-lsx/src/server/index.ts
  32. 5 0
      packages/remark-lsx/vitest.config.ts
  33. 1 1
      packages/slack/package.json
  34. 261 148
      pnpm-lock.yaml

+ 1 - 1
.mcp.json

@@ -14,7 +14,7 @@
         "--context",
         "ide-assistant",
         "--project",
-        "/workspace/growi"
+        "."
       ],
       "env": {}
     }

+ 186 - 0
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -0,0 +1,186 @@
+# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
+
+## 🎯 目標
+現在のパフォーマンス問題を解決:
+- **問題**: 5000件の兄弟ページで初期レンダリングが重い
+- **目標**: 表示速度を10-20倍改善、UX維持
+
+## ✅ 戦略2: API軽量化 - **完了済み**
+
+### 実装済み内容
+- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
+- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
+- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
+- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
+
+### 実現できた効果
+- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
+- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
+- **状況**: **実装完了・効果発現中**
+
+---
+
+## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
+
+### 前回のreact-window失敗原因
+1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
+2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
+3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
+
+### 現実的制約の認識
+**ItemsTree/TreeItemLayoutは廃止困難**:
+- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
+- **共通副作用処理**: rename/duplicate/delete時のmutation処理
+- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
+
+## 📋 修正された実装戦略: **ハイブリッドアプローチ**
+
+### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
+
+**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
+
+1. **ItemsTree**: UIロジック・副作用処理はそのまま
+2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
+3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
+4. **Virtualization**: ItemsTree内部にreact-virtualを導入
+
+### **実装計画: 段階的移行**
+
+#### **Phase 1: データ層のheadless-tree化**
+
+**ファイル**: `ItemsTree.tsx`
+```typescript
+// Before: 複雑なSWR + 子コンポーネント管理
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: initialItemNode.page._id,
+  dataLoader: {
+    getItem: async (itemId) => {
+      const response = await apiv3Get('/page-listing/item', { id: itemId });
+      return response.data;
+    },
+    getChildren: async (itemId) => {
+      const response = await apiv3Get('/page-listing/children', { id: itemId });
+      return response.data.children.map(child => child._id);
+    },
+  },
+  features: [asyncDataLoaderFeature],
+});
+
+// 既存のCustomTreeItemに渡すためのアダプター
+const adaptedNodes = tree.getItems().map(item => 
+  new ItemNode(item.getItemData())
+);
+
+return (
+  <ul className={`${moduleClass} list-group`}>
+    {adaptedNodes.map(node => (
+      <CustomTreeItem
+        key={node.page._id}
+        itemNode={node}
+        // ... 既存のpropsをそのまま渡す
+        onRenamed={onRenamed}
+        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickDeleteMenuItem={onClickDeleteMenuItem}
+      />
+    ))}
+  </ul>
+);
+```
+
+#### **Phase 2: Virtualization導入**
+
+**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
+```typescript
+const virtualizer = useVirtualizer({
+  count: adaptedNodes.length,
+  getScrollElement: () => containerRef.current,
+  estimateSize: () => 40,
+});
+
+return (
+  <div ref={containerRef} className="tree-container">
+    <div style={{ height: virtualizer.getTotalSize() }}>
+      {virtualizer.getVirtualItems().map(virtualItem => {
+        const node = adaptedNodes[virtualItem.index];
+        return (
+          <div
+            key={node.page._id}
+            style={{
+              position: 'absolute',
+              top: virtualItem.start,
+              height: virtualItem.size,
+              width: '100%',
+            }}
+          >
+            <CustomTreeItem
+              itemNode={node}
+              // ... 既存props
+            />
+          </div>
+        );
+      })}
+    </div>
+  </div>
+);
+```
+
+#### **Phase 3 (将来): 完全なheadless-tree移行**
+
+最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
+
+## 📁 現実的なファイル変更まとめ
+
+| アクション | ファイル | 内容 | スコープ |
+|---------|---------|------|------|
+| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
+| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
+| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
+| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
+| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
+| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
+
+**新規ファイル**: 1個(データローダー分離のみ)  
+**変更ファイル**: 2個(ItemsTree改修 + store整理)  
+**削除ファイル**: 0個(既存アーキテクチャ尊重)
+
+---
+
+## 🎯 実装優先順位
+
+**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
+
+**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
+- **工数**: 2-3日
+- **リスク**: 低(外部IF変更なし)
+- **効果**: 非同期ローディング最適化、キャッシュ改善
+
+**📋 Phase 2-B**: Virtualization導入  
+- **工数**: 2-3日
+- **リスク**: 低(内部実装のみ)
+- **効果**: レンダリング性能10-20倍改善
+
+**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
+**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
+
+---
+
+## 🏗️ 実装方針: **既存アーキテクチャ尊重**
+
+**基本方針**:
+- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
+- **データ管理層のみ**をheadless-tree化  
+- **外部インターフェース**は変更せず、内部最適化に集中
+- **段階的移行**で低リスク実装
+
+**今回のスコープ**:
+- ✅ 非同期データローディング最適化
+- ✅ Virtualizationによる大量要素対応  
+- ❌ drag&drop/selection(将来フェーズ)
+- ❌ 既存アーキテクチャの破壊的変更
+
+---
+
+## 技術的参考資料
+- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
+- **react-virtual**: @tanstack/react-virtualを使用  
+- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 17 - 1
CHANGELOG.md

@@ -1,9 +1,25 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.1...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.3.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.3.2](https://github.com/growilabs/compare/v7.3.1...v7.3.2) - 2025-09-29
+
+### 🚀 Improvement
+
+* imprv: Elasticsearch management retrieving status (#10330) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Ensure login errors display regardless of password login configuration (#10347) @yuki-takei
+* fix: TreeItem opening logic (#10331) @yuki-takei
+
+### 🧰 Maintenance
+
+* support: Improve memory leak (#10329) @yuki-takei
+* support: Fix biome errors (#10338) @yuki-takei
+
 ## [v7.3.1](https://github.com/growilabs/compare/v7.3.0...v7.3.1) - 2025-09-22
 
 ### 🧰 Maintenance

+ 2 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.2-RC.0",
+  "version": "7.3.3-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
@@ -273,9 +273,7 @@
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",

+ 1 - 1
apps/app/src/client/util/apiv1-client.ts

@@ -46,7 +46,7 @@ export async function apiPost<T>(path: string, params: unknown = {}): Promise<T>
 }
 
 export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
-  return apiPost<T>(path, formData);
+  return apiRequest<T>('postForm', path, formData);
 }
 
 export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -50,7 +50,7 @@ export async function apiv3Post<T = any>(path: string, params: unknown = {}): Pr
 }
 
 export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
-  return apiv3Post<T>(path, formData);
+  return apiv3Request('postForm', path, formData);
 }
 
 export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {

+ 3 - 11
apps/app/src/interfaces/page-listing-results.ts

@@ -1,19 +1,11 @@
-import type { IPageHasId } from '@growi/core';
-
-import type { IPageForItem } from './page';
-
-type ParentPath = string;
+import type { IPageForTreeItem } from './page';
 
 export interface RootPageResult {
-  rootPage: IPageHasId;
-}
-
-export interface AncestorsChildrenResult {
-  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>;
+  rootPage: IPageForTreeItem;
 }
 
 export interface ChildrenResult {
-  children: Partial<IPageForItem>[];
+  children: IPageForTreeItem[];
 }
 
 export interface V5MigrationStatus {

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

@@ -21,6 +21,20 @@ export type IPageForItem = Partial<
   IPageHasId & { processData?: IPageOperationProcessData }
 >;
 
+export type IPageForTreeItem = Pick<
+  IPageHasId,
+  | '_id'
+  | 'path'
+  | 'parent'
+  | 'descendantCount'
+  | 'revision'
+  | 'grant'
+  | 'isEmpty'
+  | 'wip'
+> & {
+  processData?: IPageOperationProcessData;
+};
+
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',
   notGranted: 'notGranted',

+ 3 - 6
apps/app/src/server/crowi/index.js

@@ -38,7 +38,7 @@ import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
-import PageOperationService from '../service/page-operation';
+import instanciatePageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
@@ -91,7 +91,7 @@ class Crowi {
   /** @type {import('../service/page-grant').default} */
   pageGrantService;
 
-  /** @type {import('../service/page-operation').default} */
+  /** @type {import('../service/page-operation').IPageOperationService} */
   pageOperationService;
 
   /** @type {PassportService} */
@@ -734,10 +734,7 @@ Crowi.prototype.setupPageService = async function() {
     this.pageService = new PageService(this);
     await this.pageService.createTtlIndex();
   }
-  if (this.pageOperationService == null) {
-    this.pageOperationService = new PageOperationService(this);
-    await this.pageOperationService.init();
-  }
+  this.pageOperationService = instanciatePageOperationService(this);
 };
 
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 34 - 0
apps/app/src/server/models/openapi/page-listing.ts

@@ -0,0 +1,34 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PageForTreeItem:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            $ref: '#/components/schemas/ObjectId'
+ *          path:
+ *            $ref: '#/components/schemas/PagePath'
+ *          parent:
+ *            $ref: '#/components/schemas/PagePath'
+ *          grant:
+ *            $ref: '#/components/schemas/PageGrant'
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          descendantCount:
+ *            type: number
+ *          isEmpty:
+ *           type: boolean
+ *          wip:
+ *            type: boolean
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */

+ 9 - 80
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageInfoForListing, IPageInfo, IPage, IUserHasId,
+  IPageInfoForListing, IPageInfo, IUserHasId,
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
@@ -10,9 +10,11 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import type { IPageForTreeItem } from '~/interfaces/page';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
+import { pageListingService } from '~/server/service/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import type Crowi from '../../crowi';
@@ -87,88 +89,17 @@ const routerFactory = (crowi: Crowi): Router => {
    *               type: object
    *               properties:
    *                 rootPage:
-   *                   $ref: '#/components/schemas/Page'
+   *                   $ref: '#/components/schemas/PageForTreeItem'
    */
   router.get('/root',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-      const Page = mongoose.model<IPage, PageModel>('Page');
-
-      let rootPage;
       try {
-        rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        return res.apiv3({ rootPage });
       }
       catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
-
-      return res.apiv3({ rootPage });
-    });
-
-  /**
-   * @swagger
-   *
-   * /page-listing/ancestors-children:
-   *   get:
-   *     tags: [PageListing]
-   *     security:
-   *       - bearer: []
-   *       - accessTokenInQuery: []
-   *     summary: /page-listing/ancestors-children
-   *     description: Get the ancestors and children of a page
-   *     parameters:
-   *       - name: path
-   *         in: query
-   *         required: true
-   *         schema:
-   *           type: string
-   *     responses:
-   *       200:
-   *         description: Get the ancestors and children of a page
-   *         content:
-   *           application/json:
-   *             schema:
-   *               type: object
-   *               properties:
-   *                 ancestorsChildren:
-   *                   type: object
-   *                   additionalProperties:
-   *                     type: object
-   *                     properties:
-   *                       _id:
-   *                         type: string
-   *                         description: Document ID
-   *                       descendantCount:
-   *                         type: integer
-   *                         description: Number of descendants
-   *                       isEmpty:
-   *                         type: boolean
-   *                         description: Indicates if the node is empty
-   *                       grant:
-   *                         type: integer
-   *                         description: Access level
-   *                       path:
-   *                         type: string
-   *                         description: Path string
-   *                       revision:
-   *                         type: string
-   *                         nullable: true
-   *                         description: Revision ID (nullable)
-   */
-  router.get('/ancestors-children',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-      const { path } = req.query;
-
-      const pageService = crowi.pageService;
-      try {
-        const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
-        return res.apiv3({ ancestorsChildren });
-      }
-      catch (err) {
-        logger.error('Failed to get ancestorsChildren.', err);
-        return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
-      }
-
     });
 
   /**
@@ -202,7 +133,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 children:
    *                   type: array
    *                   items:
-   *                     $ref: '#/components/schemas/Page'
+   *                     $ref: '#/components/schemas/PageForTreeItem'
    */
   /*
    * In most cases, using id should be prioritized
@@ -212,14 +143,12 @@ const routerFactory = (crowi: Crowi): Router => {
     loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
 
-      const pageService = crowi.pageService;
-
       const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
       const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
       try {
-        const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
         );
         return res.apiv3({ children: pages });
       }

+ 1 - 6
apps/app/src/server/routes/apiv3/pages/index.js

@@ -1,5 +1,6 @@
 
 import { PageGrant } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
@@ -9,7 +10,6 @@ import { body, query } from 'express-validator';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -53,7 +53,6 @@ module.exports = (crowi) => {
     ],
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
@@ -210,10 +209,6 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/ObjectId'
    *                  path:
    *                    $ref: '#/components/schemas/PagePath'
-   *                  revisionId:
-   *                    type: string
-   *                    description: revision ID
-   *                    example: 5e07345972560e001761fa63
    *                  newPagePath:
    *                    type: string
    *                    description: new path

+ 1 - 0
apps/app/src/server/service/page-listing/index.ts

@@ -0,0 +1 @@
+export * from './page-listing';

+ 407 - 0
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -0,0 +1,407 @@
+import type { IPage, IUser } from '@growi/core/dist/interfaces';
+import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
+import mongoose from 'mongoose';
+import type { HydratedDocument, Model } from 'mongoose';
+
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type { PageModel } from '~/server/models/page';
+import type { IPageOperation } from '~/server/models/page-operation';
+
+import { pageListingService } from './page-listing';
+
+// Mock the page-operation service
+vi.mock('~/server/service/page-operation', () => ({
+  pageOperationService: {
+    generateProcessInfo: vi.fn((pageOperations: IPageOperation[]) => {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const processInfo: Record<string, any> = {};
+      pageOperations.forEach((pageOp) => {
+        const pageId = pageOp.page._id.toString();
+        processInfo[pageId] = {
+          [pageOp.actionType]: {
+            [PageActionStage.Main]: { isProcessable: true },
+            [PageActionStage.Sub]: undefined,
+          },
+        };
+      });
+      return processInfo;
+    }),
+  },
+}));
+
+describe('page-listing store integration tests', () => {
+  let Page: PageModel;
+  let User: Model<IUser>;
+  let PageOperation: Model<IPageOperation>;
+  let testUser: HydratedDocument<IUser>;
+  let rootPage: HydratedDocument<IPage>;
+
+  // Helper function to validate IPageForTreeItem type structure
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const validatePageForTreeItem = (page: any): void => {
+    expect(page).toBeDefined();
+    expect(page._id).toBeDefined();
+    expect(typeof page.path).toBe('string');
+    expect(page.grant).toBeDefined();
+    expect(typeof page.isEmpty).toBe('boolean');
+    expect(typeof page.descendantCount).toBe('number');
+    // revision is required when isEmpty is false
+    if (page.isEmpty === false) {
+      expect(page.revision).toBeDefined();
+      expect(isValidObjectId(page.revision)).toBe(true);
+    }
+    // processData is optional
+    if (page.processData !== undefined) {
+      expect(page.processData).toBeInstanceOf(Object);
+    }
+  };
+
+  beforeAll(async() => {
+    // setup models
+    const setupPage = (await import('~/server/models/page')).default;
+    setupPage(null);
+    const setupUser = (await import('~/server/models/user')).default;
+    setupUser(null);
+
+    // get models
+    Page = mongoose.model<IPage, PageModel>('Page');
+    User = mongoose.model<IUser>('User');
+    PageOperation = (await import('~/server/models/page-operation')).default;
+  });
+
+  beforeEach(async() => {
+    // Clean up database
+    await Page.deleteMany({});
+    await User.deleteMany({});
+    await PageOperation.deleteMany({});
+
+    // Create test user
+    testUser = await User.create({
+      name: 'Test User',
+      username: 'testuser',
+      email: 'test@example.com',
+      lang: 'en_US',
+    });
+
+    // Create root page
+    rootPage = await Page.create({
+      path: '/',
+      revision: new mongoose.Types.ObjectId(),
+      creator: testUser._id,
+      lastUpdateUser: testUser._id,
+      grant: 1, // GRANT_PUBLIC
+      isEmpty: false,
+      descendantCount: 0,
+    });
+  });
+
+  describe('pageListingService.findRootByViewer', () => {
+    test('should return root page successfully', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      expect(rootPageResult).toBeDefined();
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+      expect(rootPageResult.grant).toBe(1);
+      expect(rootPageResult.isEmpty).toBe(false);
+      expect(rootPageResult.descendantCount).toBe(0);
+    });
+
+    test('should handle error when root page does not exist', async() => {
+      // Remove the root page
+      await Page.deleteOne({ path: '/' });
+
+      try {
+        await pageListingService.findRootByViewer(testUser);
+        // Should not reach here
+        expect(true).toBe(false);
+      }
+      catch (error) {
+        expect(error).toBeDefined();
+      }
+    });
+
+    test('should return proper page structure that matches IPageForTreeItem type', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      // Use helper function to validate type structure
+      validatePageForTreeItem(rootPageResult);
+
+      // Additional type-specific validations
+      expect(typeof rootPageResult._id).toBe('object'); // ObjectId
+      expect(rootPageResult.path).toBe('/');
+      expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
+      expect(rootPageResult.parent).toBeNull(); // Root page has no parent
+    });
+
+    test('should work without user (guest access) and return type-safe result', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer();
+
+      validatePageForTreeItem(rootPageResult);
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+    });
+  });
+
+  describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
+    let childPage1: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create child pages
+      childPage1 = await Page.create({
+        path: '/child1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 1,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/child2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create grandchild page
+      await Page.create({
+        path: '/child1/grandchild',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: childPage1._id,
+      });
+
+      // Update root page descendant count
+      await Page.updateOne(
+        { _id: rootPage._id },
+        { descendantCount: 2 },
+      );
+    });
+
+    test('should find children by parent path and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+        expect(['/child1', '/child2']).toContain(child.path);
+      });
+    });
+
+    test('should find children by parent ID and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+      });
+    });
+
+    test('should handle nested children correctly', async() => {
+      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+
+      expect(nestedChildren).toHaveLength(1);
+      const grandChild = nestedChildren[0];
+      validatePageForTreeItem(grandChild);
+      expect(grandChild.path).toBe('/child1/grandchild');
+      expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
+    });
+
+    test('should return empty array when no children exist', async() => {
+      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+
+      expect(noChildren).toHaveLength(0);
+      expect(Array.isArray(noChildren)).toBe(true);
+    });
+
+    test('should work without user (guest access)', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+      });
+    });
+
+    test('should sort children by path in ascending order', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      expect(children[0].path).toBe('/child1');
+      expect(children[1].path).toBe('/child2');
+    });
+  });
+
+  describe('pageListingService processData injection', () => {
+    let operatingPage: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create a page that will have operations
+      operatingPage = await Page.create({
+        path: '/operating-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create a PageOperation for this page
+      await PageOperation.create({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Main,
+        page: {
+          _id: operatingPage._id,
+          path: operatingPage.path,
+          isEmpty: operatingPage.isEmpty,
+          grant: operatingPage.grant,
+          grantedGroups: [],
+          descendantCount: operatingPage.descendantCount,
+        },
+        user: {
+          _id: testUser._id,
+        },
+        fromPath: '/operating-page',
+        toPath: '/renamed-operating-page',
+        options: {},
+      });
+    });
+
+    test('should inject processData for pages with operations', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // Find the operating page in results
+      const operatingResult = children.find(child => child.path === '/operating-page');
+      expect(operatingResult).toBeDefined();
+
+      // Validate type structure
+      if (operatingResult) {
+        validatePageForTreeItem(operatingResult);
+
+        // Check that processData was injected
+        expect(operatingResult.processData).toBeDefined();
+        expect(operatingResult.processData).toBeInstanceOf(Object);
+      }
+    });
+
+    test('should set processData to undefined for pages without operations', async() => {
+      // Create another page without operations
+      await Page.create({
+        path: '/normal-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const normalPage = children.find(child => child.path === '/normal-page');
+
+      expect(normalPage).toBeDefined();
+      if (normalPage) {
+        validatePageForTreeItem(normalPage);
+        expect(normalPage.processData).toBeUndefined();
+      }
+    });
+
+    test('should maintain type safety with mixed processData scenarios', async() => {
+      // Create pages with and without operations
+      await Page.create({
+        path: '/mixed-test-1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/mixed-test-2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // All results should be type-safe regardless of processData presence
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+
+        // processData should be either undefined or a valid object
+        if (child.processData !== undefined) {
+          expect(child.processData).toBeInstanceOf(Object);
+        }
+      });
+    });
+  });
+
+  describe('PageQueryBuilder exec() type safety tests', () => {
+    test('findRootByViewer should return object with correct _id type', async() => {
+      const result = await pageListingService.findRootByViewer(testUser);
+
+      // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
+      expect(result._id).toBeDefined();
+      expect(result._id.toString).toBeDefined();
+      expect(typeof result._id.toString()).toBe('string');
+      expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
+    });
+
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+      // Create test child page first
+      await Page.create({
+        path: '/test-child',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(Array.isArray(results)).toBe(true);
+      results.forEach((result) => {
+        // Validate _id behavior from exec() any return type
+        expect(result._id).toBeDefined();
+        expect(result._id.toString).toBeDefined();
+        expect(typeof result._id.toString()).toBe('string');
+        expect(result._id.toString().length).toBe(24);
+
+        // Validate parent _id behavior
+        if (result.parent) {
+          expect(result.parent.toString).toBeDefined();
+          expect(typeof result.parent.toString()).toBe('string');
+          expect(result.parent.toString().length).toBe(24);
+        }
+      });
+    });
+
+  });
+});

+ 114 - 0
apps/app/src/server/service/page-listing/page-listing.ts

@@ -0,0 +1,114 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose, { type HydratedDocument } from 'mongoose';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
+import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import PageOperation from '~/server/models/page-operation';
+
+import type { IPageOperationService } from '../page-operation';
+
+const { hasSlash, generateChildrenRegExp } = pagePathUtils;
+
+
+export interface IPageListingService {
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findChildrenByParentPathOrIdAndViewer(
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner?: boolean,
+    showPagesRestrictedByGroup?: boolean,
+  ): Promise<IPageForTreeItem[]>,
+}
+
+let pageOperationService: IPageOperationService;
+async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
+  if (pageOperationService == null) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+  }
+  return pageOperationService;
+}
+
+class PageListingService implements IPageListingService {
+
+  async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
+    await builder.addViewerCondition(user);
+
+    return builder.query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+  }
+
+  async findChildrenByParentPathOrIdAndViewer(
+      parentPathOrId: string,
+      user?: IUser,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<IPageForTreeItem[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+    }
+    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    // Type-safe conversion to IPageForTreeItem
+    return injectedPages.map(page => (
+      Object.assign(page, { _id: page._id.toString() })
+    ));
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes<T>(
+      pages: HydratedDocument<T>[],
+      actionTypes: PageActionType[],
+  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return pages.map(page => Object.assign(page, { processData: undefined }));
+    }
+
+    const pageOperationService = await getPageOperationServiceInstance();
+    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    return pages.map((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        return Object.assign(page, { processData });
+      }
+      return Object.assign(page, { processData: undefined });
+    });
+  }
+
+}
+
+export const pageListingService = new PageListingService();

+ 15 - 2
apps/app/src/server/service/page-operation.ts

@@ -25,7 +25,15 @@ const {
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 
-class PageOperationService {
+export interface IPageOperationService {
+  generateProcessInfo(pageOperations: PageOperationDocument[]): IPageOperationProcessInfo;
+  canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean>;
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
+}
+
+class PageOperationService implements IPageOperationService {
 
   crowi: Crowi;
 
@@ -201,4 +209,9 @@ class PageOperationService {
 
 }
 
-export default PageOperationService;
+// eslint-disable-next-line import/no-mutable-exports
+export let pageOperationService: PageOperationService | undefined; // singleton instance
+export default function instanciate(crowi: Crowi): PageOperationService {
+  pageOperationService = new PageOperationService(crowi);
+  return pageOperationService;
+}

+ 3 - 105
apps/app/src/server/service/page/index.ts

@@ -33,9 +33,7 @@ import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import {
-  type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
-} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -4312,45 +4310,10 @@ class PageService implements IPageService {
     return savedPage;
   }
 
-  /*
-   * Find all children by parent's path or id. Using id should be prioritized
-   */
-  async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user,
-      userGroups = null,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
-  ): Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-    let queryBuilder: PageQueryBuilder;
-    if (hasSlash(parentPathOrId)) {
-      const path = parentPathOrId;
-      const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
-      const parentId = parentPathOrId;
-      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
-    }
-    await queryBuilder.addViewerCondition(user, userGroups, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    return pages;
-  }
-
   /**
    * Find all pages in trash page
    */
-  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<PageDocument[]> {
+  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<HydratedDocument<IPage>[]> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
     // https://regex101.com/r/KYZWls/1
@@ -4360,80 +4323,15 @@ class PageService implements IPageService {
 
     await queryBuilder.addViewerCondition(user, userGroups);
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<IPage>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .query
       .lean()
       .exec();
 
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
     return pages;
   }
 
-  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-    const regexps = ancestorPaths.map(path => generateChildrenRegExp(path)); // cannot use re2
-
-    // get pages at once
-    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
-    await queryBuilder.addViewerCondition(user, userGroups);
-    const pages = await queryBuilder
-      .addConditionAsOnTree()
-      .addConditionToMinimizeDataForRendering()
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    /*
-     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-     */
-    const pathToChildren: Record<string, PageDocument[]> = {};
-    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-    sortedPaths.every((path) => {
-      const children = pages.filter(page => pathlib.dirname(page.path) === path);
-      if (children.length === 0) {
-        return false; // break when children do not exist
-      }
-      pathToChildren[path] = children;
-      return true;
-    });
-
-    return pathToChildren;
-  }
-
-  /**
-   * Inject processData into page docuements
-   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
-   */
-  private async injectProcessDataIntoPagesByActionTypes(
-      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
-      actionTypes: PageActionType[],
-  ): Promise<void> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
-    if (pageOperations == null || pageOperations.length === 0) {
-      return;
-    }
-
-    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
-    const operatingPageIds: string[] = Object.keys(processInfo);
-
-    // inject processData into pages
-    pages.forEach((page) => {
-      const pageId = page._id.toString();
-      if (operatingPageIds.includes(pageId)) {
-        const processData: IPageOperationProcessData = processInfo[pageId];
-        page.processData = processData;
-      }
-    });
-  }
-
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsService = getYjsService();
 

+ 0 - 4
apps/app/src/server/service/page/page-service.ts

@@ -31,10 +31,6 @@ export interface IPageService {
   findPageAndMetaDataByViewer(
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
   ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
-  findChildrenByParentPathOrIdAndViewer(
-    parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
-  ): Promise<PageDocument[]>,
   resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],

+ 0 - 1
apps/pdf-converter/package.json

@@ -36,7 +36,6 @@
     "@tsed/schema": "=8.5.0",
     "@tsed/swagger": "=8.5.0",
     "@tsed/terminus": "=8.5.0",
-    "axios": "^0.24.0",
     "express": "^4.19.2",
     "puppeteer": "^23.1.1",
     "puppeteer-cluster": "^0.24.0",

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.3.2-slackbot-proxy.0",
+  "version": "7.3.3-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -45,7 +45,7 @@
     "@tsed/schema": "=6.43.0",
     "@tsed/swagger": "=6.43.0",
     "@tsed/typeorm": "=6.43.0",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "body-parser": "^1.20.3",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",

+ 4 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.3.2-RC.0",
+  "version": "7.3.3-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -47,6 +47,9 @@
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
     "@playwright/test": "^1.49.1",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/react": "^16.0.1",
+    "@testing-library/react-hooks": "^8.0.1",
     "@swc-node/register": "^1.10.9",
     "@swc/core": "^1.5.25",
     "@swc/helpers": "^0.5.11",

+ 1 - 1
packages/pdf-converter-client/package.json

@@ -12,7 +12,7 @@
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"
   },
   "dependencies": {
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "tslib": "^2.8.0"
   },
   "devDependencies": {

+ 8 - 3
packages/remark-attachment-refs/package.json

@@ -41,16 +41,16 @@
     "lint:styles": "stylelint \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "run-p lint:*",
-    "test": ""
+    "test": "vitest run --coverage"
   },
   "dependencies": {
     "@growi/core": "workspace:^",
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/ui": "workspace:^",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "bunyan": "^1.8.15",
-    "hast-util-select": "^6.0.2",
     "express": "^4.20.0",
+    "hast-util-select": "^6.0.2",
     "mongoose": "^6.13.6",
     "swr": "^2.3.2",
     "universal-bunyan": "^0.9.2",
@@ -59,10 +59,15 @@
   "devDependencies": {
     "@types/bunyan": "^1.8.11",
     "@types/hast": "^3.0.4",
+    "@types/react": "^18.2.14",
+    "@types/react-dom": "^18.2.6",
+    "@types/supertest": "^6.0.2",
     "csstype": "^3.0.2",
+    "happy-dom": "^15.7.4",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
     "npm-run-all": "^4.1.5",
+    "supertest": "^7.0.0",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0"
   },

+ 2 - 0
packages/remark-attachment-refs/src/@types/declaration.d.ts

@@ -0,0 +1,2 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.scss';

+ 298 - 0
packages/remark-attachment-refs/src/client/stores/refs.spec.ts

@@ -0,0 +1,298 @@
+// tests for assuring axios request succeeds in version change
+
+import type { Server } from 'node:http';
+import { renderHook, waitFor } from '@testing-library/react';
+import axios from 'axios';
+import express from 'express';
+import refsMiddleware from '../../server';
+
+import { useSWRxRef, useSWRxRefs } from './refs';
+
+// Mock the IAttachmentHasId type for testing
+const mockAttachment = {
+  _id: '507f1f77bcf86cd799439011',
+  fileFormat: 'image/jpeg',
+  fileName: 'test-image.jpg',
+  originalName: 'test-image.jpg',
+  filePath: 'attachment/507f1f77bcf86cd799439011.jpg',
+  creator: {
+    _id: '507f1f77bcf86cd799439012',
+    name: 'Test User',
+    username: 'testuser',
+  },
+  page: '507f1f77bcf86cd799439013',
+  createdAt: '2023-01-01T00:00:00.000Z',
+  fileSize: 1024000,
+};
+
+// Mock PageQueryBuilder
+const mockPageQueryBuilder = {
+  addConditionToListWithDescendants: vi.fn().mockReturnThis(),
+  addConditionToExcludeTrashed: vi.fn().mockReturnThis(),
+  query: {
+    select: vi.fn().mockReturnValue({
+      exec: vi.fn().mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+    }),
+    and: vi.fn().mockReturnThis(),
+  },
+};
+
+vi.mock('mongoose', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('mongoose')>();
+  return {
+    ...actual,
+    default: {
+      ...actual.default,
+      model: vi.fn().mockImplementation((modelName) => {
+        const mockModel = {
+          findByPathAndViewer: vi.fn().mockResolvedValue({
+            _id: '507f1f77bcf86cd799439013',
+            path: '/test-page',
+          }),
+          isAccessiblePageByViewer: vi.fn().mockResolvedValue(true),
+          find: vi.fn().mockReturnValue({
+            select: vi.fn().mockReturnValue({
+              exec: vi
+                .fn()
+                .mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+            }),
+            and: vi.fn().mockReturnThis(),
+          }),
+          addConditionToFilteringByViewerForList: vi.fn(),
+          PageQueryBuilder: vi
+            .fn()
+            .mockImplementation(() => mockPageQueryBuilder),
+        };
+
+        if (modelName === 'Attachment') {
+          return {
+            findOne: vi.fn().mockReturnValue({
+              populate: vi.fn().mockResolvedValue(mockAttachment),
+            }),
+            find: vi.fn().mockReturnValue({
+              and: vi.fn().mockReturnThis(),
+              populate: vi.fn().mockReturnThis(),
+              exec: vi.fn().mockResolvedValue([mockAttachment]),
+            }),
+          };
+        }
+
+        return mockModel;
+      }),
+    },
+    model: vi.fn().mockImplementation((modelName) => {
+      const mockModel = {
+        findByPathAndViewer: vi.fn().mockResolvedValue({
+          _id: '507f1f77bcf86cd799439013',
+          path: '/test-page',
+        }),
+        isAccessiblePageByViewer: vi.fn().mockResolvedValue(true),
+        find: vi.fn().mockReturnValue({
+          select: vi.fn().mockReturnValue({
+            exec: vi
+              .fn()
+              .mockResolvedValue([{ id: '507f1f77bcf86cd799439013' }]),
+          }),
+          and: vi.fn().mockReturnThis(),
+        }),
+        addConditionToFilteringByViewerForList: vi.fn(),
+        PageQueryBuilder: vi
+          .fn()
+          .mockImplementation(() => mockPageQueryBuilder),
+      };
+
+      if (modelName === 'Attachment') {
+        return {
+          findOne: vi.fn().mockReturnValue({
+            populate: vi.fn().mockResolvedValue(mockAttachment),
+          }),
+          find: vi.fn().mockReturnValue({
+            and: vi.fn().mockReturnThis(),
+            populate: vi.fn().mockReturnThis(),
+            exec: vi.fn().mockResolvedValue([mockAttachment]),
+          }),
+        };
+      }
+
+      return mockModel;
+    }),
+  };
+});
+
+// Mock @growi/core modules
+vi.mock('@growi/core', () => ({
+  SCOPE: {
+    READ: { FEATURES: { PAGE: 'read:page' } },
+  },
+}));
+
+vi.mock('@growi/core/dist/models/serializers', () => ({
+  serializeAttachmentSecurely: vi
+    .fn()
+    .mockImplementation((attachment) => attachment),
+}));
+
+vi.mock('@growi/core/dist/remark-plugins', () => ({
+  OptionParser: {
+    parseRange: vi.fn().mockReturnValue({ start: 1, end: 3 }),
+  },
+}));
+
+// Mock FilterXSS
+vi.mock('xss', () => ({
+  FilterXSS: vi.fn().mockImplementation(() => ({
+    process: vi.fn().mockImplementation((input) => input),
+  })),
+}));
+
+const TEST_PORT = 3002;
+const TEST_SERVER_URL = `http://localhost:${TEST_PORT}`;
+
+describe('useSWRxRef and useSWRxRefs integration tests', () => {
+  let server: Server;
+  let app: express.Application;
+
+  const setupAxiosSpy = () => {
+    const originalAxios = axios.create();
+    return vi.spyOn(axios, 'get').mockImplementation((url, config) => {
+      const fullUrl = url.startsWith('/_api')
+        ? `${TEST_SERVER_URL}${url}`
+        : url;
+      return originalAxios.get(fullUrl, config);
+    });
+  };
+
+  beforeAll(async () => {
+    app = express();
+    app.use(express.json());
+    app.use(express.urlencoded({ extended: true }));
+
+    app.use((req, res, next) => {
+      res.header('Access-Control-Allow-Origin', '*');
+      res.header(
+        'Access-Control-Allow-Methods',
+        'GET, POST, PUT, DELETE, OPTIONS',
+      );
+      res.header(
+        'Access-Control-Allow-Headers',
+        'Origin, X-Requested-With, Content-Type, Accept, Authorization',
+      );
+      next();
+    });
+
+    const mockCrowi = {
+      require: () => () => (req: any, res: any, next: any) => next(),
+      accessTokenParser: () => (req: any, res: any, next: any) => {
+        req.user = { _id: '507f1f77bcf86cd799439012', username: 'testuser' };
+        next();
+      },
+    };
+
+    refsMiddleware(mockCrowi, app);
+
+    return new Promise<void>((resolve) => {
+      server = app.listen(TEST_PORT, () => {
+        resolve();
+      });
+    });
+  });
+
+  afterAll(() => {
+    return new Promise<void>((resolve) => {
+      if (server) {
+        server.close(() => {
+          resolve();
+        });
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  describe('useSWRxRef', () => {
+    it('should make actual server request and receive attachment data for single ref request', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRef('/test-page', 'test-image.jpg', false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/ref',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '/test-page',
+            fileNameOrId: 'test-image.jpg',
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+  });
+
+  describe('useSWRxRefs', () => {
+    it('should make actual server request and receive attachments data for refs request with pagePath', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRefs('/test-page', undefined, {}, false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/refs',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '/test-page',
+            prefix: undefined,
+            options: {},
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+
+    it('should make actual server request and receive attachments data for refs request with prefix', async () => {
+      const axiosGetSpy = setupAxiosSpy();
+
+      const { result } = renderHook(() =>
+        useSWRxRefs('', '/test-prefix', { depth: '2' }, false),
+      );
+
+      await waitFor(() => expect(result.current.data).toBeDefined(), {
+        timeout: 5000,
+      });
+
+      expect(axiosGetSpy).toHaveBeenCalledWith(
+        '/_api/attachment-refs/refs',
+        expect.objectContaining({
+          params: expect.objectContaining({
+            pagePath: '',
+            prefix: '/test-prefix',
+            options: { depth: '2' },
+          }),
+        }),
+      );
+
+      expect(result.current.data).toBeDefined();
+      expect(result.current.error).toBeUndefined();
+
+      axiosGetSpy.mockRestore();
+    });
+  });
+});

+ 4 - 3
packages/remark-attachment-refs/src/server/routes/refs.ts

@@ -179,9 +179,10 @@ export const routesFactory = (crowi): any => {
     async (req: RequestWithUser, res) => {
       const user = req.user;
       const { prefix, pagePath } = req.query;
-      const options: Record<string, string | undefined> = JSON.parse(
-        req.query.options?.toString() ?? '',
-      );
+      const options: Record<string, string | undefined> =
+        typeof req.query.options === 'string'
+          ? JSON.parse(req.query.options)
+          : (req.query.options ?? {});
 
       // check either 'prefix' or 'pagePath ' is specified
       if (prefix == null && pagePath == null) {

+ 1 - 0
packages/remark-attachment-refs/tsconfig.json

@@ -7,6 +7,7 @@
     "paths": {
       "~/*": ["./src/*"]
     },
+    "types": ["vitest/globals"],
 
     /* TODO: remove below flags for strict checking */
     "strict": false,

+ 16 - 0
packages/remark-attachment-refs/vitest.config.ts

@@ -0,0 +1,16 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [tsconfigPaths()],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+    environmentMatchGlobs: [
+      // Use jsdom for client-side tests
+      ['**/client/**/*.spec.ts', 'jsdom'],
+      ['**/client/**/*.test.ts', 'jsdom'],
+    ],
+  },
+});

+ 3 - 2
packages/remark-lsx/package.json

@@ -47,10 +47,11 @@
   "devDependencies": {
     "@types/express": "^4",
     "@types/hast": "^3.0.4",
-    "axios": "^0.24.0",
-    "is-absolute-url": "^4.0.1",
+    "axios": "^1.11.0",
     "hast-util-sanitize": "^5.0.1",
     "hast-util-select": "^6.0.2",
+    "is-absolute-url": "^4.0.1",
+    "jsdom": "^26.1.0",
     "unified": "^11.0.0",
     "unist-util-visit": "^5.0.0"
   },

+ 153 - 0
packages/remark-lsx/src/client/stores/lsx/lsx.spec.ts

@@ -0,0 +1,153 @@
+// tests for assuring axios request succeeds in version change
+
+import type { Server } from 'node:http';
+import { renderHook, waitFor } from '@testing-library/react';
+import axios from 'axios';
+import express from 'express';
+import lsxMiddleware from '../../../server';
+
+import { useSWRxLsx } from './lsx';
+
+// Mock the generateBaseQuery function
+vi.mock('../../../server/routes/list-pages/generate-base-query', () => ({
+  generateBaseQuery: vi.fn().mockResolvedValue({
+    query: {
+      skip: vi.fn().mockReturnThis(),
+      limit: vi.fn().mockReturnThis(),
+      sort: vi.fn().mockReturnThis(),
+      and: vi.fn().mockReturnThis(),
+      clone: vi.fn().mockReturnThis(),
+      count: vi.fn().mockResolvedValue(10),
+      exec: vi.fn().mockResolvedValue([]),
+    },
+    addConditionToListOnlyDescendants: vi.fn().mockReturnThis(),
+    addConditionToFilteringByViewerForList: vi.fn().mockReturnThis(),
+  }),
+}));
+
+// Mock mongoose model
+vi.mock('mongoose', () => ({
+  model: vi.fn().mockReturnValue({
+    find: vi.fn().mockReturnValue({
+      skip: vi.fn().mockReturnThis(),
+      limit: vi.fn().mockReturnThis(),
+      sort: vi.fn().mockReturnThis(),
+      and: vi.fn().mockReturnThis(),
+      clone: vi.fn().mockReturnThis(),
+      count: vi.fn().mockResolvedValue(10),
+      exec: vi.fn().mockResolvedValue([]),
+    }),
+    countDocuments: vi.fn().mockResolvedValue(0),
+    aggregate: vi.fn().mockResolvedValue([{ count: 5 }]),
+  }),
+}));
+
+const TEST_PORT = 3001;
+const TEST_SERVER_URL = `http://localhost:${TEST_PORT}`;
+
+describe('useSWRxLsx integration tests', () => {
+  let server: Server;
+  let app: express.Application;
+
+  // Helper function to setup axios spy
+  const setupAxiosSpy = () => {
+    const originalAxios = axios.create();
+    return vi.spyOn(axios, 'get').mockImplementation((url, config) => {
+      const fullUrl = url.startsWith('/_api')
+        ? `${TEST_SERVER_URL}${url}`
+        : url;
+      return originalAxios.get(fullUrl, config);
+    });
+  };
+
+  beforeAll(async () => {
+    // Create minimal Express app with just the LSX route
+    app = express();
+    app.use(express.json());
+    app.use(express.urlencoded({ extended: true }));
+
+    // Add CORS headers to prevent cross-origin issues
+    app.use((req, res, next) => {
+      res.header('Access-Control-Allow-Origin', '*');
+      res.header(
+        'Access-Control-Allow-Methods',
+        'GET, POST, PUT, DELETE, OPTIONS',
+      );
+      res.header(
+        'Access-Control-Allow-Headers',
+        'Origin, X-Requested-With, Content-Type, Accept, Authorization',
+      );
+      next();
+    });
+
+    // Mock minimal GROWI-like structure for the middleware
+    const mockCrowi = {
+      require: () => () => (req: any, res: any, next: any) => next(),
+      accessTokenParser: () => (req: any, res: any, next: any) => next(),
+    };
+
+    // Import and setup the LSX middleware
+    lsxMiddleware(mockCrowi, app);
+
+    // Start test server
+    return new Promise<void>((resolve) => {
+      server = app.listen(TEST_PORT, () => {
+        resolve();
+      });
+    });
+  });
+
+  afterAll(() => {
+    return new Promise<void>((resolve) => {
+      if (server) {
+        server.close(() => {
+          resolve();
+        });
+      } else {
+        resolve();
+      }
+    });
+  });
+
+  it('should make actual server request and receive 2xx response for basic lsx request', async () => {
+    const axiosGetSpy = setupAxiosSpy();
+
+    const { result } = renderHook(() =>
+      useSWRxLsx('/test-page', { depth: '1' }, false),
+    );
+
+    await waitFor(() => expect(result.current.data).toBeDefined(), {
+      timeout: 5000,
+    });
+
+    expect(axiosGetSpy).toHaveBeenCalledWith(
+      '/_api/lsx',
+      expect.objectContaining({
+        params: expect.objectContaining({
+          pagePath: '/test-page',
+          options: expect.objectContaining({ depth: '1' }),
+        }),
+      }),
+    );
+
+    expect(result.current.data).toBeDefined();
+    expect(result.current.error).toBeUndefined();
+
+    axiosGetSpy.mockRestore();
+  });
+
+  it('should handle server validation errors properly', async () => {
+    const axiosGetSpy = setupAxiosSpy();
+
+    const { result } = renderHook(() => useSWRxLsx('', {}, false));
+
+    await waitFor(() => expect(result.current.error).toBeDefined(), {
+      timeout: 5000,
+    });
+
+    expect(result.current.error).toBeDefined();
+    expect(result.current.data).toBeUndefined();
+
+    axiosGetSpy.mockRestore();
+  });
+});

+ 2 - 1
packages/remark-lsx/src/server/index.ts

@@ -19,7 +19,8 @@ const lsxValidator = [
     .optional()
     .customSanitizer((options) => {
       try {
-        const jsonData: LsxApiOptions = JSON.parse(options);
+        const jsonData: LsxApiOptions =
+          typeof options === 'string' ? JSON.parse(options) : options;
 
         for (const key in jsonData) {
           jsonData[key] = filterXSS.process(jsonData[key]);

+ 5 - 0
packages/remark-lsx/vitest.config.ts

@@ -7,5 +7,10 @@ export default defineConfig({
     environment: 'node',
     clearMocks: true,
     globals: true,
+    environmentMatchGlobs: [
+      // Use jsdom for client-side tests
+      ['**/client/**/*.spec.ts', 'jsdom'],
+      ['**/client/**/*.test.ts', 'jsdom'],
+    ],
   },
 });

+ 1 - 1
packages/slack/package.json

@@ -54,7 +54,7 @@
     "@types/bunyan": "^1.8.10",
     "@types/http-errors": "^2.0.3",
     "@types/url-join": "^4.0.2",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "browser-bunyan": "^1.6.3",
     "bunyan": "^1.8.15",
     "crypto": "^1.0.1",

Разница между файлами не показана из-за своего большого размера
+ 261 - 148
pnpm-lock.yaml


Некоторые файлы не были показаны из-за большого количества измененных файлов