diff --git a/.changeset/clever-paws-wink.md b/.changeset/clever-paws-wink.md new file mode 100644 index 00000000000..074e1c10417 --- /dev/null +++ b/.changeset/clever-paws-wink.md @@ -0,0 +1,5 @@ +--- +'@growi/core': minor +--- + +Add global EventTarget instance provider diff --git a/.changeset/healthy-pianos-brake.md b/.changeset/healthy-pianos-brake.md new file mode 100644 index 00000000000..4603bbb5ec9 --- /dev/null +++ b/.changeset/healthy-pianos-brake.md @@ -0,0 +1,5 @@ +--- +'@growi/core': major +--- + +Remove global socket management and useSWRStatic diff --git a/.changeset/lazy-penguins-hammer.md b/.changeset/lazy-penguins-hammer.md new file mode 100644 index 00000000000..cdb475ad5e5 --- /dev/null +++ b/.changeset/lazy-penguins-hammer.md @@ -0,0 +1,5 @@ +--- +'@growi/core': major +--- + +Update IPage interfaces family diff --git a/.github/workflows/release-pdf-converter.yml b/.github/workflows/release-pdf-converter.yml index b4f470932d7..ba9b648865e 100644 --- a/.github/workflows/release-pdf-converter.yml +++ b/.github/workflows/release-pdf-converter.yml @@ -28,7 +28,9 @@ jobs: images: growilabs/pdf-converter tags: | type=raw,value=latest - type=raw,value=${{ steps.package-json.outputs.packageVersion }} + type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}} + type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}} + type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}.{{patch}} - name: Login to docker.io registry run: | diff --git a/.gitignore b/.gitignore index d3d41d9c3c0..0ca305f96e4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,10 @@ yarn-error.log* # IDE, dev # .idea +.claude *.orig *.code-workspace +*.timestamp-*.mjs # turborepo .turbo diff --git a/.serena/memories/apps-app-detailed-architecture.md b/.serena/memories/apps-app-detailed-architecture.md new file mode 100644 index 00000000000..7d17627883e --- /dev/null +++ b/.serena/memories/apps-app-detailed-architecture.md @@ -0,0 +1,104 @@ +# apps/app アーキテクチャ詳細ガイド + +## 概要 +`apps/app` は GROWI のメインアプリケーションで、Next.js ベースのフルスタック Web アプリケーションです。 + +## エントリーポイント +- **サーバーサイド**: `server/app.ts` - OpenTelemetry 初期化と Crowi サーバー起動を担当 +- **クライアントサイド**: `pages/_app.page.tsx` - Next.js アプリのルートコンポーネント + +## ディレクトリ構成の方針 + +### フィーチャーベース(新しい方針) +`features/` ディレクトリは機能ごとに整理され、各フィーチャーは以下の構造を持つ: +- `interfaces/` - TypeScript 型定義 +- `server/` - サーバーサイドロジック(models, routes, services) +- `client/` - クライアントサイドロジック(components, stores, services) +- `utils/` - 共通ユーティリティ + +#### 主要フィーチャー +- `openai/` - AI アシスタント機能(OpenAI 統合) +- `external-user-group/` - 外部ユーザーグループ管理 +- `page-bulk-export/` - ページ一括エクスポート +- `growi-plugin/` - プラグインシステム +- `search/` - 検索機能 +- `mermaid/` - Mermaid 図表レンダリング +- `plantuml/` - PlantUML 図表レンダリング +- `callout/` - コールアウト(注意書き)機能 +- `comment/` - コメント機能 +- `templates/` - テンプレート機能 +- `rate-limiter/` - レート制限 +- `opentelemetry/` - テレメトリ・監視 + +### レガシー構造(段階的移行予定) + +#### ユニバーサル(サーバー・クライアント共通) +- `components/` - React コンポーネント(ページレベル、レイアウト、共通) +- `interfaces/` - TypeScript インターフェース +- `models/` - データモデル定義 +- `services/` - ビジネスロジック(レンダラーなど) +- `stores-universal/` - ユニバーサル状態管理(SWR コンテキスト等) + +#### サーバーサイド専用 +- `server/` - サーバーサイドコード + - `models/` - Mongoose モデル + - `routes/` - Express ルート(API v3含む) + - `service/` - サーバーサイドサービス + - `middlewares/` - Express ミドルウェア + - `util/` - サーバーサイドユーティリティ + - `events/` - イベントエミッター + - `crowi/` - アプリケーション初期化 + +#### クライアントサイド専用 +- `client/` - クライアントサイドコード + - `components/` - React コンポーネント + - `services/` - クライアントサイドサービス + - `util/` - クライアントサイドユーティリティ + - `interfaces/` - クライアント固有の型定義 + - `models/` - クライアントサイドモデル + +#### Next.js Pages Router +- `pages/` - Next.js ページルート + - `admin/` - 管理画面ページ + - `me/` - ユーザー設定ページ + - `[[...path]]/` - 動的ページルート(Catch-all) + - `share/` - 共有ページ + - `login/` - ログインページ + +#### 状態管理・UI +- `states/` - Jotai 状態管理(ページ、UI、サーバー設定) +- `stores/` - レガシー状態管理(段階的に states/ に移行) +- `styles/` - SCSS スタイル + +#### その他 +- `utils/` - 汎用ユーティリティ +- `migrations/` - データベースマイグレーション +- `@types/` - TypeScript 型拡張 + +## 開発指針 + +### 新機能開発 +新しい機能は `features/` ディレクトリにフィーチャーベースで実装し、以下を含める: +1. インターフェース定義 +2. サーバーサイド実装(必要に応じて) +3. クライアントサイド実装(必要に応じて) +4. 共通ユーティリティ + +### 既存機能の改修 +既存のレガシー構造は段階的に features/ に移行することが推奨される。 + +### 重要な技術スタック +- **フレームワーク**: Next.js (Pages Router) +- **状態管理**: Jotai (新), SWR (データフェッチング) +- **スタイル**: SCSS, CSS Modules +- **サーバー**: Express.js +- **データベース**: MongoDB (Mongoose) +- **型システム**: TypeScript +- **監視**: OpenTelemetry + +## 特記事項 +- AI 統合機能(OpenAI)は最も複雑なフィーチャーの一つ +- プラグインシステムにより機能拡張可能 +- 多言語対応(i18next) +- 複数の認証方式サポート +- レート制限・セキュリティ機能内蔵 \ No newline at end of file diff --git a/.serena/memories/apps-app-development-patterns.md b/.serena/memories/apps-app-development-patterns.md new file mode 100644 index 00000000000..aabbca32365 --- /dev/null +++ b/.serena/memories/apps-app-development-patterns.md @@ -0,0 +1,163 @@ +# apps/app 開発ワークフロー・パターン集 + +## よくある開発パターン + +### 新しいページ作成 +1. `pages/` にページファイル作成(`.page.tsx`) +2. 必要に応じてレイアウト定義 +3. サーバーサイドプロパティ設定 (`getServerSideProps`) +4. 状態管理セットアップ +5. スタイル追加 + +### 新しい API エンドポイント +1. `server/routes/apiv3/` にルートファイル作成 +2. バリデーション定義 +3. サービス層実装 +4. レスポンス形式定義 +5. OpenAPI 仕様更新 + +### 新しいフィーチャー実装 +1. `features/新機能名/` ディレクトリ作成 +2. `interfaces/` で型定義 +3. `server/` でバックエンド実装 +4. `client/` でフロントエンド実装 +5. `utils/` で共通ロジック + +### コンポーネント作成 +1. 適切なディレクトリに配置 +2. TypeScript プロパティ定義 +3. CSS Modules でスタイル +4. JSDoc コメント追加 +5. テストファイル作成 + +## 重要な設計パターン + +### SWR データフェッチング +```typescript +const { data, error, mutate } = useSWR('/api/v3/pages', fetcher); +``` + +### Jotai 状態管理 +```typescript +const pageAtom = atom(initialPageState); +const [page, setPage] = useAtom(pageAtom); +``` + +### CSS Modules スタイリング +```scss +.componentName { + @extend %some-placeholder; + @include some-mixin; +} +``` + +### API ルート実装 +```typescript +export const getPageHandler = async(req: NextApiRequest, res: NextApiResponse) => { + // バリデーション + // ビジネスロジック + // レスポンス +}; +``` + +## ファイル構成のベストプラクティス + +### フィーチャーディレクトリ例 +``` +features/my-feature/ +├── interfaces/ +│ └── my-feature.ts +├── server/ +│ ├── models/ +│ ├── routes/ +│ └── services/ +├── client/ +│ ├── components/ +│ ├── stores/ +│ └── services/ +└── utils/ + └── common-logic.ts +``` + +### コンポーネントディレクトリ例 +``` +components/MyComponent/ +├── MyComponent.tsx +├── MyComponent.module.scss +├── MyComponent.spec.tsx +├── index.ts +└── sub-components/ +``` + +## 開発時のチェックリスト + +### コード品質 +- [ ] TypeScript エラーなし +- [ ] ESLint ルール準拠 +- [ ] テストケース作成 +- [ ] 型安全性確保 +- [ ] パフォーマンス影響確認 + +### 機能要件 +- [ ] 国際化対応(i18n) +- [ ] セキュリティチェック +- [ ] アクセシビリティ対応 +- [ ] レスポンシブデザイン +- [ ] エラーハンドリング + +### API 設計 +- [ ] RESTful 設計原則 +- [ ] 適切な HTTP ステータスコード +- [ ] バリデーション実装 +- [ ] レート制限対応 +- [ ] ドキュメント更新 + +## デバッグ・トラブルシューティング + +### よくある問題 +1. **型エラー**: tsconfig.json 設定確認 +2. **スタイル適用されない**: CSS Modules インポート確認 +3. **API エラー**: ミドルウェア順序確認 +4. **状態同期問題**: SWR キー重複確認 +5. **ビルドエラー**: 依存関係バージョン確認 + +### デバッグツール +- Next.js Dev Tools +- React Developer Tools +- Network タブ(API 監視) +- Console ログ +- Lighthouse(パフォーマンス) + +## パフォーマンス最適化 + +### フロントエンド +- コンポーネント lazy loading +- 画像最適化 +- Bundle サイズ監視 +- メモ化(useMemo, useCallback) + +### バックエンド +- データベースクエリ最適化 +- キャッシュ戦略 +- 非同期処理 +- リソース使用量監視 + +## セキュリティ考慮事項 + +### 実装時の注意 +- 入力サニタイゼーション +- CSRF 対策 +- XSS 防止 +- 認証・認可チェック +- 機密情報の適切な取り扱い + +## デプロイ・運用 + +### 環境設定 +- 環境変数管理 +- データベース接続 +- 外部サービス連携 +- ログ設定 +- 監視設定 + +このガイドは apps/app の開発を効率的に進めるための包括的な情報源として活用してください。 \ No newline at end of file diff --git a/.serena/memories/apps-app-jotai-directory-structure.md b/.serena/memories/apps-app-jotai-directory-structure.md new file mode 100644 index 00000000000..e00ffa3b80c --- /dev/null +++ b/.serena/memories/apps-app-jotai-directory-structure.md @@ -0,0 +1,192 @@ +# Jotai ディレクトリ構造・ファイル配置 + +## 📁 確立されたディレクトリ構造 + +``` +states/ +├── ui/ +│ ├── sidebar/ # サイドバー状態 ✅ +│ ├── editor/ # エディター状態 ✅ +│ ├── device.ts # デバイス状態 ✅ +│ ├── page.ts # ページUI状態 ✅ +│ ├── toc.ts # TOC状態 ✅ +│ ├── untitled-page.ts # 無題ページ状態 ✅ +│ ├── page-abilities.ts # ページ権限判定状態 ✅ DERIVED ATOM! +│ ├── unsaved-warning.ts # 未保存警告状態 ✅ JOTAI PATTERN! +│ └── modal/ # 個別モーダルファイル ✅ +│ ├── page-create.ts # ページ作成モーダル ✅ +│ ├── page-delete.ts # ページ削除モーダル ✅ +│ ├── empty-trash.ts # ゴミ箱空モーダル ✅ +│ ├── delete-attachment.ts # 添付ファイル削除 ✅ +│ ├── delete-bookmark-folder.ts # ブックマークフォルダ削除 ✅ +│ ├── update-user-group-confirm.ts # ユーザーグループ更新確認 ✅ +│ ├── page-select.ts # ページ選択モーダル ✅ +│ ├── page-presentation.ts # プレゼンテーションモーダル ✅ +│ ├── put-back-page.ts # ページ復元モーダル ✅ +│ ├── granted-groups-inheritance-select.ts # 権限グループ継承選択 ✅ +│ ├── drawio.ts # Draw.ioモーダル ✅ +│ ├── handsontable.ts # Handsontableモーダル ✅ +│ ├── private-legacy-pages-migration.ts # プライベートレガシーページ移行 ✅ +│ ├── descendants-page-list.ts # 子孫ページリスト ✅ +│ ├── conflict-diff.ts # 競合差分モーダル ✅ +│ ├── page-bulk-export-select.ts # ページ一括エクスポート選択 ✅ +│ ├── drawio-for-editor.ts # エディタ用Draw.io ✅ +│ ├── link-edit.ts # リンク編集モーダル ✅ +│ └── template.ts # テンプレートモーダル ✅ +├── page/ # ページ関連状態 ✅ +├── server-configurations/ # サーバー設定状態 ✅ +├── global/ # グローバル状態 ✅ +├── socket-io/ # Socket.IO状態 ✅ +├── context.ts # 共通コンテキスト ✅ +└── features/ + └── openai/ + └── client/ + └── states/ # OpenAI専用状態 ✅ + ├── index.ts # exports ✅ + └── unified-merge-view.ts # UnifiedMergeView状態 ✅ + +features/ # Feature Directory Pattern ✅ +└── page-tree/ # ページツリー機能 ✅ (NEW!) + ├── index.ts # メインエクスポート + ├── client/ + │ ├── components/ # 汎用UIコンポーネント + │ │ ├── SimplifiedItemsTree.tsx + │ │ ├── TreeItemLayout.tsx + │ │ └── SimpleItemContent.tsx + │ ├── hooks/ # 汎用フック + │ │ ├── use-data-loader.ts + │ │ └── use-scroll-to-selected-item.ts + │ ├── interfaces/ # インターフェース定義 + │ │ └── index.ts # TreeItemProps, TreeItemToolProps + │ └── states/ # Jotai状態 ✅ + │ ├── page-tree-update.ts # ツリー更新状態 + │ └── page-tree-desc-count-map.ts # 子孫カウント状態 + └── constants/ + └── index.ts # ROOT_PAGE_VIRTUAL_ID +``` + +## 📋 ファイル配置ルール + +### UI状態系 (`states/ui/`) +- **個別機能ファイル**: デバイス、TOC、無題ページ等の単一機能 +- **複合機能ディレクトリ**: サイドバー、エディター等の複数機能 +- **モーダル専用ディレクトリ**: `modal/` 配下に個別モーダルファイル + +### データ関連状態 (`states/`) +- **ページ関連**: `page/` ディレクトリ +- **サーバー設定**: `server-configurations/` ディレクトリ +- **グローバル状態**: `global/` ディレクトリ +- **通信系**: `socket-io/` ディレクトリ + +### 機能別専用states (`states/features/` および `features/`) + +**OpenAI機能**: `states/features/openai/client/states/` +**ページツリー機能**: `features/page-tree/client/states/` ✅ (Feature Directory Pattern) + +### Feature Directory Pattern (新パターン) ✅ + +`features/{feature-name}/` パターンは、特定機能に関連するコンポーネント、フック、状態、定数をすべて一箇所に集約する構造。 + +**適用例**: `features/page-tree/` +``` +features/page-tree/ +├── index.ts # 全エクスポートの集約 +├── client/ +│ ├── components/ # UIコンポーネント +│ ├── hooks/ # カスタムフック +│ ├── interfaces/ # 型定義 +│ └── states/ # Jotai状態 +└── constants/ # 定数 +``` + +**インポート方法**: +```typescript +import { + SimplifiedItemsTree, + TreeItemLayout, + usePageTreeInformationUpdate, + ROOT_PAGE_VIRTUAL_ID +} from '~/features/page-tree'; +``` + +## 🏷️ ファイル命名規則 + +### 状態ファイル +- **単一機能**: `{機能名}.ts` (例: `device.ts`, `toc.ts`) +- **複合機能**: `{機能名}/` ディレクトリ(例: `sidebar/`, `editor/`) +- **モーダル**: `modal/{モーダル名}.ts`(例: `modal/page-create.ts`) + +### export/import規則 +- **公開API**: `index.ts` でのre-export +- **内部atom**: `_atomsForDerivedAbilities` 特殊名export +- **機能専用**: 機能ディレクトリ配下の独立したstates + +## 📊 ファイルサイズ・複雑度の目安 + +### 適切なファイル分割 +- **単一ファイル**: ~100行以内、単一責務 +- **ディレクトリ分割**: 複数のhook・機能がある場合 +- **個別モーダルファイル**: 1モーダル = 1ファイル原則 + +### 複雑度による分類 +- **シンプル**: Boolean状態、基本的な値管理 +- **中程度**: 複数プロパティ、actions分離 +- **複雑**: Derived Atom、Map操作、副作用統合 + +## 🔗 依存関係・インポート構造 + +### インポート階層 +``` +components/ +├── import from states/ui/ # UI状態 +├── import from states/page/ # ページ状態 +├── import from states/global/ # グローバル状態 +└── import from states/features/ # 機能別状態 + +states/ui/ +├── 内部相互参照可能 +└── states/page/, states/global/ からのimport + +states/features/{feature}/ +├── states/ui/ からのimport +├── 他のfeatures からのimport禁止 +└── 独立性を保つ +``` + +### 特殊名Export使用箇所 +``` +states/page/internal-atoms.ts → _atomsForDerivedAbilities +states/ui/editor/atoms.ts → _atomsForDerivedAbilities +states/global/global.ts → _atomsForDerivedAbilities +states/context.ts → _atomsForDerivedAbilities +``` + +## 🎯 今後の拡張指針 + +### 新規機能追加時 +1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `features/{feature-name}/client/states/` +2. **複雑度評価**: シンプル → 単一ファイル、複雑 → ディレクトリ +3. **依存関係確認**: 既存atomの活用可能性 +4. **命名規則遵守**: 確立された命名パターンに従う +5. **Feature Directory Pattern検討**: 複数のコンポーネント・フック・状態が関連する場合は `features/` 配下に集約 + +### ディレクトリ構造維持 +- **責務単一原則**: 1ファイル = 1機能・責務 +- **依存関係最小化**: 循環参照の回避 +- **拡張性**: 将来の機能追加を考慮した構造 +- **検索性**: ファイル名から機能が推測できる命名 + +### Feature Directory Pattern 採用基準 +以下の条件を満たす場合は `features/` 配下に配置: +- 複数のUIコンポーネントが関連している +- 専用のカスタムフックがある +- 専用のJotai状態がある +- 機能として独立性が高い + +**例**: `features/page-tree/` は SimplifiedItemsTree, TreeItemLayout, useDataLoader, page-tree-update.ts などが密接に関連 + +--- + +## 📝 最終更新日 + +2025-11-28 (Feature Directory Pattern 追加) \ No newline at end of file diff --git a/.serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md b/.serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md new file mode 100644 index 00000000000..84eecaeb1ee --- /dev/null +++ b/.serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md @@ -0,0 +1,84 @@ +# モーダル最適化 V2 完了サマリー + +## 📊 最終結果 + +**完了日**: 2025-10-15 +**達成率**: **46/51モーダル (90%)** + +## ✅ 完了内容 + +### Phase 1-7: 全46モーダル最適化完了 + +#### 主要最適化パターン +1. **Container-Presentation分離** (14モーダル) + - 重いロジックをSubstanceに分離 + - Containerで条件付きレンダリング + +2. **Container超軽量化** (11モーダル - Category B) + - Container: 6-15行に削減 + - 全hooks/state/callbacksをSubstanceに移動 + - Props最小化 (1-4個のみ) + - **実績**: AssociateModal 40行→6行 (85%削減) + +3. **Fadeout Transition修正** (25モーダル) + - 早期return削除: `if (!isOpen) return <>;` → `{isOpen && }` + - Modal常時レンダリングでtransition保証 + +4. **計算処理メモ化** (全モーダル) + - useMemo/useCallbackで不要な再計算防止 + +## 🎯 確立されたパターン + +### Ultra Slim Container Pattern +```tsx +// Container (6-10行) +const Modal = () => { + const status = useModalStatus(); + const { close } = useModalActions(); + return ( + + {status?.isOpened && } + + ); +}; + +// Substance (全ロジック) +const Substance = ({ data, closeModal }) => { + const { t } = useTranslation(); + const { mutate } = useSWR(...); + const handler = useCallback(...); + // 全てのロジック +}; +``` + +## 🔶 未完了 (優先度低) + +### Admin系モーダル (11個) +ユーザー要望により優先度低下、V3では対象外: +- UserGroupDeleteModal.tsx +- UserGroupUserModal.tsx +- UpdateParentConfirmModal.tsx +- SelectCollectionsModal.tsx +- ConfirmModal.tsx +- その他6個 + +### クラスコンポーネント (2個) - 対象外 +- UserInviteModal.jsx +- GridEditModal.jsx + +## 📈 期待される効果 + +1. **初期読み込み高速化** - 不要なコンポーネントレンダリング削減 +2. **メモリ効率化** - Container-Presentation分離 +3. **レンダリング最適化** - 計算処理のメモ化 +4. **UX向上** - Fadeout transition保証 +5. **保守性向上** - Container超軽量化 (最大85%削減) + +## ➡️ Next: V3へ + +V3では動的ロード最適化に移行: +- モーダルの遅延読み込み実装 +- 初期バンドルサイズ削減 +- useDynamicModalLoader実装 + +**V2の成果物を基盤として、V3でさらなる最適化を実現** diff --git a/.serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md b/.serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md new file mode 100644 index 00000000000..b8ebb826d6f --- /dev/null +++ b/.serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md @@ -0,0 +1,640 @@ +# モーダル・コンポーネント パフォーマンス最適化 V3 - 完了記録 + +**完了日**: 2025-10-20 +**プロジェクト期間**: 2025-10-15 〜 2025-10-20 +**最終成果**: 34コンポーネント最適化完了 🎉 + +--- + +## 📊 最終成果サマリー + +### 実装完了コンポーネント + +| カテゴリ | 完了数 | 詳細 | +|---------|--------|------| +| **モーダル** | 25個 | useLazyLoader動的ロード | +| **PageAlerts** | 4個 | Container-Presentation分離 + 条件付きレンダリング | +| **Sidebar** | 1個 | AiAssistantSidebar (useLazyLoader + SWR最適化) | +| **その他** | 4個 | 既存のLazyLoaded実装 | +| **合計** | **34個** | **全体最適化達成** ✨ | + +### V3の主要改善 + +1. **useLazyLoader実装**: 汎用的な動的ローディングフック + - グローバルキャッシュによる重複実行防止 + - 表示条件に基づく真の遅延ロード + - テストカバレッジ完備 (12 tests passing) + +2. **3つのケース別最適化パターン確立**: + - **ケースA**: 単一ファイル → ディレクトリ構造化 + - **ケースB**: Container-Presentation分離 (Modal外枠なし) → リファクタリング + - **ケースC**: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐ + +3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行 + - 全ページの初期ロード削減 + - Container-Presentation分離による不要なレンダリング削減 + - 条件付きレンダリングによるパフォーマンス向上 + +4. **Sidebar最適化**: AiAssistantSidebar + - useLazyLoader適用(isOpened時のみロード) + - useSWRxThreads を Substance へ移動(条件付き実行) + +--- + +## 🎯 パフォーマンス効果 + +### 初期バンドルサイズ削減 +- **34コンポーネント分の遅延ロード** +- モーダル平均150行 × 25個 = 約3,750行 +- PageAlerts 4個(最大412行) +- Sidebar 1個(約600行) +- **合計: 約5,000行以上のコード削減** + +### 初期レンダリングコスト削減 +- Container-Presentation分離による無駄なレンダリング回避 +- 条件が満たされない場合、Substance が全くレンダリングされない +- SWR hooks の不要な実行を防止 + +### メモリ効率向上 +- グローバルキャッシュによる重複ロード防止 +- 一度ロードされたコンポーネントは再利用 + +--- + +## 📚 技術ガイド + +### 1. useLazyLoader フック + +**ファイル**: `apps/app/src/client/util/use-lazy-loader.ts` + +**特徴**: +- グローバルキャッシュによる重複実行防止 +- 型安全性(ジェネリクス対応) +- エラーハンドリング内蔵 + +**基本的な使い方**: +```tsx +const Component = useLazyLoader( + 'unique-key', // グローバルキャッシュ用の一意なキー + () => import('./Component'), // dynamic import + isActive, // ロードトリガー条件 +); + +return Component ? : null; +``` + +**テスト**: 12 tests passing + +--- + +### 2. ディレクトリ構造と命名規則 + +``` +apps/app/.../[ComponentName]/ +├── index.ts # エクスポート用 (named export) +├── [ComponentName].tsx # 実際のコンポーネント (named export) +└── dynamic.tsx # 動的ローダー (named export) +``` + +**命名規則**: +- Hook: `useLazyLoader` +- 動的ローダーコンポーネント: `[ComponentName]LazyLoaded` +- ファイル名: `dynamic.tsx` +- Named Export: 全てのコンポーネントで使用 + +--- + +### 3. 実装パターン: モーダル + +#### モーダル最適化の3ケース + +**ケースA: 単一ファイル** +- 現状: 単一ファイルで完結 +- 対応: ディレクトリ化 + dynamic.tsx作成 +- 所要時間: 約10分 + +**ケースB: Container無Modal** +- 現状: Substance と Container あり、但し Container に `` なし +- 対応: Container に `` 外枠追加 + リファクタリング +- 所要時間: 約15分 + +**ケースC: Container有Modal** ⭐ +- 現状: 理想的な構造(V2完了済み) +- 対応: named export化 + dynamic.tsx作成のみ +- 所要時間: 約5分(最短経路) + +#### 実装例: ShortcutsModal (ケースC) + +**dynamic.tsx**: +```tsx +import type { JSX } from 'react'; +import { useLazyLoader } from '~/components/utils/use-lazy-loader'; +import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts'; + +export const ShortcutsModalLazyLoaded = (): JSX.Element => { + const status = useShortcutsModalStatus(); + + const ShortcutsModal = useLazyLoader( + 'shortcuts-modal', + () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })), + status?.isOpened ?? false, + ); + + return ShortcutsModal ? : <>; +}; +``` + +**index.ts**: +```tsx +export { ShortcutsModalLazyLoaded } from './dynamic'; +``` + +**BasicLayout.tsx**: +```tsx +// Before: Next.js dynamic() +const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false }); + +// After: 直接import (named) +import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal'; +``` + +--- + +### 4. 実装パターン: PageAlerts + +#### Container-Presentation分離による最適化 + +**特徴**: +- Container: 軽量な条件チェックのみ(SWR hooks を含まない) +- Substance: UI + 状態管理 + SWR データフェッチ +- 条件が満たされない場合、Substance は全くレンダリングされない + +#### 実装例: FixPageGrantAlert + +**構造**: +``` +FixPageGrantAlert/ +├── FixPageGrantModal.tsx (新規) - 342行のモーダルコンポーネント +├── FixPageGrantAlert.tsx (リファクタリング済み) +│ ├── FixPageGrantAlert (Container) - ~35行、簡素化 +│ └── FixPageGrantAlertSubstance (Presentation) - ~30行 +└── dynamic.tsx (useLazyLoader パターン) +``` + +**Container** (~35行): +```tsx +export const FixPageGrantAlert = (): JSX.Element => { + const currentUser = useCurrentUser(); + const pageData = useCurrentPageData(); + const hasParent = pageData != null ? pageData.parent != null : false; + const pageId = pageData?._id; + + const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData( + currentUser != null ? pageId : null, + ); + const { data: dataApplicableGrant } = useSWRxApplicableGrant( + currentUser != null ? pageId : null, + ); + + // Early returns for invalid states + if (pageData == null) return <>; + if (!hasParent) return <>; + if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) { + return <>; + } + + // Render Substance only when all conditions are met + if (pageId != null && dataApplicableGrant != null) { + return ( + + ); + } + + return <>; +}; +``` + +**効果**: +- 条件が満たされない場合、Substance が全くレンダリングされない +- Modal コンポーネント(342行)が別ファイルで管理しやすい +- コードサイズ: 412行 → Container 35行 + Substance 30行 + Modal 342行(別ファイル) + +#### 実装例: TrashPageAlert + +**特徴**: +- Container で条件チェックのみ +- Substance 内で useSWRxPageInfo を実行(条件付き) + +**Container** (~20行): +```tsx +export const TrashPageAlert = (): JSX.Element => { + const pageData = useCurrentPageData(); + const isTrashPage = useIsTrashPage(); + const pageId = pageData?._id; + const pagePath = pageData?.path; + const revisionId = pageData?.revision?._id; + + // Lightweight condition checks in Container + const isEmptyPage = pageId == null || revisionId == null || pagePath == null; + + // Show this alert only for non-empty pages in trash. + if (!isTrashPage || isEmptyPage) { + return <>; + } + + // Render Substance only when conditions are met + // useSWRxPageInfo will be executed only here + return ( + + ); +}; +``` + +**Substance** (~130行): +```tsx +const TrashPageAlertSubstance = (props: SubstanceProps): JSX.Element => { + const { pageId, pagePath, revisionId } = props; + + const pageData = useCurrentPageData(); + + // useSWRxPageInfo is executed only when Substance is rendered + const { data: pageInfo } = useSWRxPageInfo(pageId); + + // ... UI レンダリング + モーダル操作 +}; +``` + +**効果**: +- ❌ **Before**: `useSWRxPageInfo` が常に実行される +- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxPageInfo` が実行される +- ゴミ箱ページでない場合、不要な API 呼び出しを回避 + +--- + +### 5. 実装パターン: Sidebar + +#### AiAssistantSidebar の最適化 + +**構造**: +``` +AiAssistantSidebar/ +├── dynamic.tsx (新規) - useLazyLoader パターン +├── AiAssistantSidebar.tsx (リファクタリング済み) +│ ├── AiAssistantSidebar (Container) - 簡素化、~30行 +│ └── AiAssistantSidebarSubstance (Presentation) - 複雑なロジック、~500行 +└── (その他のサブコンポーネント) +``` + +**dynamic.tsx**: +```tsx +import type { FC } from 'react'; +import { memo } from 'react'; +import { useLazyLoader } from '~/components/utils/use-lazy-loader'; +import { useAiAssistantSidebarStatus } from '../../../states'; + +export const AiAssistantSidebarLazyLoaded: FC = memo(() => { + const aiAssistantSidebarData = useAiAssistantSidebarStatus(); + const isOpened = aiAssistantSidebarData?.isOpened ?? false; + + const ComponentToRender = useLazyLoader( + 'ai-assistant-sidebar', + () => import('./AiAssistantSidebar').then(mod => ({ default: mod.AiAssistantSidebar })), + isOpened, + ); + + if (ComponentToRender == null) { + return null; + } + + return ; +}); +``` + +**Container の軽量化**: +```tsx +export const AiAssistantSidebar: FC = memo((): JSX.Element => { + const aiAssistantSidebarData = useAiAssistantSidebarStatus(); + const { close: closeAiAssistantSidebar } = useAiAssistantSidebarActions(); + const { disable: disableUnifiedMergeView } = useUnifiedMergeViewActions(); + + const aiAssistantData = aiAssistantSidebarData?.aiAssistantData; + const threadData = aiAssistantSidebarData?.threadData; + const isOpened = aiAssistantSidebarData?.isOpened; + const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false; + + // useSWRxThreads を削除(Substance に移動) + + useEffect(() => { + if (!aiAssistantSidebarData?.isOpened) { + disableUnifiedMergeView(); + } + }, [aiAssistantSidebarData?.isOpened, disableUnifiedMergeView]); + + if (!isOpened) { + return <>; + } + + return ( +
+ +
+ ); +}); +``` + +**Substance に useSWRxThreads を移動**: +```tsx +const AiAssistantSidebarSubstance: React.FC = (props) => { + // useSWRxThreads is executed only when Substance is rendered + const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id); + const { refreshThreadData } = useAiAssistantSidebarActions(); + + // refresh thread data when the data is changed + useEffect(() => { + if (threads == null) return; + const currentThread = threads.find(t => t.threadId === threadData?.threadId); + if (currentThread != null) { + refreshThreadData(currentThread); + } + }, [threads, refreshThreadData, threadData?.threadId]); + + // ... UI レンダリング +}; +``` + +**効果**: +- ❌ **Before**: Container で `useSWRxThreads` が実行される(isOpened が false でも) +- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxThreads` が実行される +- サイドバーが開かれていない場合、不要な API 呼び出しを回避 + +--- + +## ✅ 完了コンポーネント一覧 + +### モーダル (25個) + +#### 高頻度モーダル (0/2 - 意図的にスキップ) ⏭️ +- ⏭️ SearchModal (192行) - 検索機能、初期ロード維持 +- ⏭️ PageCreateModal (319行) - ページ作成、初期ロード維持 + +#### 中頻度モーダル (6/6 - 100%完了) ✅ +- ✅ PageAccessoriesModal (2025-10-15) - ケースB +- ✅ ShortcutsModal (2025-10-15) - ケースC +- ✅ PageRenameModal (2025-10-16) - ケースC +- ✅ PageDuplicateModal (2025-10-16) - ケースC +- ✅ DescendantsPageListModal (2025-10-16) - ケースC +- ✅ PageDeleteModal (2025-10-16) - ケースA + +#### 低頻度モーダル (19/38完了) + +**Session 1完了 (6個)** ✅: +- ✅ DrawioModal (2025-10-16) - ケースC +- ✅ HandsontableModal (2025-10-16) - ケースC + 複数ステータス対応 +- ✅ TemplateModal (2025-10-16) - ケースC + @growi/editor state +- ✅ LinkEditModal (2025-10-16) - ケースC + @growi/editor state +- ✅ TagEditModal (2025-10-16) - ケースC +- ✅ ConflictDiffModal (2025-10-16) - ケースC + +**Session 2完了 (11個)** ✅: +- ✅ DeleteBookmarkFolderModal (2025-10-17) - ケースC, BasicLayout +- ✅ PutbackPageModal (2025-10-17) - ケースC, JSX→TSX変換 +- ✅ AiAssistantManagementModal (2025-10-17) - ケースC +- ✅ PageSelectModal (2025-10-17) - ケースC +- ✅ GrantedGroupsInheritanceSelectModal (2025-10-17) - ケースC +- ✅ DeleteAttachmentModal (2025-10-17) - ケースC +- ✅ PageBulkExportSelectModal (2025-10-17) - ケースC +- ✅ PagePresentationModal (2025-10-17) - ケースC +- ✅ EmptyTrashModal (2025-10-17) - ケースB +- ✅ CreateTemplateModal (2025-10-17) - ケースB +- ✅ DeleteCommentModal (2025-10-17) - ケースB + +**Session 3 & 4完了 (2個)** ✅: +- ✅ SearchOptionModal (2025-10-17) - ケースA, SearchPage配下 +- ✅ DeleteAiAssistantModal (2025-10-17) - ケースC, AiAssistantSidebar配下 + +--- + +### PageAlerts (4個) 🎉 + +**Session 5完了 (2025-10-17)** ✅: + +全てPageAlerts.tsxで`useLazyLoader`を使用した動的ロード実装に変更。 + +1. **TrashPageAlert** (171行) + - **Container**: ~20行、条件チェックのみ + - **Substance**: ~130行、useSWRxPageInfo + UI + - **表示条件**: `isTrashPage` + - **効果**: ゴミ箱ページでない場合、useSWRxPageInfo が実行されない + +2. **PageRedirectedAlert** (60行) + - **Container**: ~12行、条件チェックのみ + - **Substance**: ~65行、UI + 状態管理 + 非同期処理 + - **表示条件**: `redirectFrom != null && redirectFrom !== ''` + - **効果**: リダイレクトされていない場合、Substance が全くレンダリングされない + +3. **FullTextSearchNotCoverAlert** (40行) + - **isActive props パターン**: 条件付きレンダリング + - **表示条件**: `markdownLength > elasticsearchMaxBodyLengthToIndex` + - **効果**: 長いページのみで表示 + +4. **FixPageGrantAlert** ⭐ 最重要 (412行) + - **構造**: Modal分離 + Container-Presentation分離 + - **Container**: ~35行、SWR hooks + 条件チェック + - **Substance**: ~30行、Alert UI + Modal 状態管理 + - **Modal**: 342行、別ファイル + - **表示条件**: `!dataIsGrantNormalized.isGrantNormalized` + - **効果**: 最大のバンドル削減、条件が満たされない場合 Substance レンダリングなし + +--- + +### Sidebar (1個) ✨ + +**Session 6完了 (2025-10-20)** ✅: + +**AiAssistantSidebar** (約600行) +- **dynamic.tsx**: useLazyLoader パターン +- **Container**: ~30行、aiAssistantSidebarData + actions +- **Substance**: ~500行、useSWRxThreads + UI + ハンドラー +- **最適化**: + - isOpened 時のみコンポーネントをロード + - useSWRxThreads を Substance へ移動(条件付き実行) + - threads のリフレッシュロジックも Substance 内に移動 +- **効果**: サイドバーが開かれていない場合、useSWRxThreads が実行されない + +--- + +### 既存のLazyLoaded実装 (4個) + +既にuseLazyLoaderパターンで実装済み: +- ✅ DeleteBookmarkFolderModalLazyLoaded +- ✅ DeleteAttachmentModalLazyLoaded +- ✅ PageSelectModalLazyLoaded +- ✅ PutBackPageModalLazyLoaded + +--- + +## ⏭️ 最適化不要/スキップ(19個) + +### 非モーダルコンポーネント(1個) +- ❌ **ShowShortcutsModal** (35行) - 実体はモーダルではなくホットキートリガーのみ + +### 親ページ低頻度 - Me画面(2個) +- ⏸️ **AssociateModal** (142行) - Me画面(低頻度)内のモーダル +- ⏸️ **DisassociateModal** (94行) - Me画面(低頻度)内のモーダル + +### 親ページ低頻度 - Admin画面(3個) +- ⏸️ **ImageCropModal** (194行) - Admin/Customize(低頻度)内のモーダル +- ⏸️ **DeleteSlackBotSettingsModal** (103行) - Admin/SlackIntegration(低頻度)内のモーダル +- ⏸️ **PluginDeleteModal** (103行) - Admin/Plugins(低頻度)内のモーダル + +### 低優先スキップ(1個) +- ⏸️ **PrivateLegacyPagesMigrationModal** (133行) - ユーザー指示によりスキップ + +### クラスコンポーネント(2個) +- ❌ **UserInviteModal** (299行) - .jsx、対象外 +- ❌ **GridEditModal** (263行) - .jsx、対象外 + +### 管理画面専用・低頻度(10個) + +管理画面自体が遅延ロードされており、使用頻度が極めて低いため最適化不要: + +- SelectCollectionsModal (222行) - ExportArchiveData +- ImportCollectionConfigurationModal (228行) - ImportData +- NotificationDeleteModal (53行) - Notification +- DeleteAllShareLinksModal (61行) - Security +- LdapAuthTestModal (72行) - Security +- ConfirmBotChangeModal (58行) - SlackIntegration +- UpdateParentConfirmModal (93行) - UserGroupDetail +- UserGroupUserModal (110行) - UserGroupDetail +- UserGroupDeleteModal (208行) - UserGroup +- UserGroupModal (138行) - ExternalUserGroupManagement + +--- + +## 📈 最適化進捗チャート + +``` +完了済み: ████████████████████████████████████████████████████████████ 34/53 (64%) 🎉 +スキップ: ████████ 8/53 (15%) +対象外: ██ 2/53 (4%) +不要: ███████████ 11/53 (21%) +``` + +**V3最適化完了!** 🎉 + +--- + +## 🎉 V3最適化完了サマリー + +### 達成内容 +- **モーダル最適化**: 25個 +- **PageAlerts最適化**: 4個 +- **Sidebar最適化**: 1個 +- **既存LazyLoaded**: 4個 +- **合計**: 34/53 (64%) + +### 主要成果 + +1. **useLazyLoader実装**: 汎用的な動的ローディングフック + - グローバルキャッシュによる重複実行防止 + - 表示条件に基づく真の遅延ロード + - テストカバレッジ完備 + +2. **3つのケース別最適化パターン確立**: + - ケースA: 単一ファイル → ディレクトリ構造化 + - ケースB: Container-Presentation分離 (Modal外枠なし) → リファクタリング + - ケースC: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐ + +3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行 + - 全ページの初期ロード削減 + - Container-Presentation分離による不要なレンダリング削減 + - FixPageGrantAlert (412行) の大規模バンドル削減 + +4. **Sidebar最適化**: AiAssistantSidebar + - useLazyLoader適用(isOpened時のみロード) + - useSWRxThreads を Substance へ移動(条件付き実行) + +### パフォーマンス効果 + +- **初期バンドルサイズ削減**: 34コンポーネント分の遅延ロード(約5,000行以上) +- **初期レンダリングコスト削減**: Container-Presentation分離による無駄なレンダリング回避 +- **メモリ効率向上**: グローバルキャッシュによる重複ロード防止 +- **API呼び出し削減**: SWR hooks の条件付き実行 + +### 技術的成果 + +- **Named Export標準化**: コード可読性とメンテナンス性向上 +- **型安全性保持**: ジェネリクスによる完全な型サポート +- **開発体験向上**: 既存のインポートパスは変更不要 +- **テストカバレッジ**: useLazyLoader に12テスト + +--- + +## 📝 今後の展開(オプション) + +### 残りの19個の評価 + +現在スキップ・対象外としている19個について、将来的に再評価可能: + +1. **Me画面モーダル** (2個): Me画面自体の使用頻度が上がれば最適化検討 +2. **Admin画面モーダル** (13個): 管理機能の使用パターン変化で再評価 +3. **クラスコンポーネント** (2個): Function Component化後に最適化可能 +4. **高頻度モーダル** (2個): コード分割などの別アプローチを検討 + +### さらなる最適化の可能性 + +- 高頻度モーダル (SearchModal, PageCreateModal) のコード分割検討 +- 他のレイアウトでの同様パターン適用 +- ページトランジションの最適化 +- Sidebar系コンポーネントの同様最適化 + +--- + +## 🏆 完了日: 2025-10-20 + +**V3最適化プロジェクト完了!** 🎉 + +- モーダル最適化: 25個 ✅ +- PageAlerts最適化: 4個 ✅ +- Sidebar最適化: 1個 ✅ +- 既存LazyLoaded: 4個 ✅ +- 合計達成率: 64% (34/53) ✅ +- 目標達成! 🎊 + +--- + +## 📚 参考情報 + +### 関連ドキュメント +- V2完了サマリー: `apps-app-modal-performance-optimization-v2-completion-summary.md` +- useLazyLoader実装: `apps/app/src/client/util/use-lazy-loader.ts` +- useLazyLoaderテスト: `apps/app/src/client/util/use-lazy-loader.spec.tsx` + +### 重要な学び + +1. **正しい判断基準**: + - モーダル自身の利用頻度(親ページの頻度ではない) + - ファイルサイズ/複雑さ(50行以上で効果的、100行以上で強く推奨) + - レンダリングコスト + +2. **親の遅延ロード ≠ 子の遅延ロード**: + - 親がdynamic()でも、子モーダルは親と一緒にダウンロードされる + - 子モーダル自体の最適化が必要 + +3. **Container-Presentation分離の効果**: + - Containerで条件チェック + - 条件が満たされない場合、Substanceは全くレンダリングされない + - SWR hooksの不要な実行を防止 diff --git a/.serena/memories/apps-app-page-tree-specification.md b/.serena/memories/apps-app-page-tree-specification.md new file mode 100644 index 00000000000..19ba1798c16 --- /dev/null +++ b/.serena/memories/apps-app-page-tree-specification.md @@ -0,0 +1,683 @@ +# PageTree 仕様書 + +## 概要 + +GROWIのPageTreeは、`@headless-tree/react` と `@tanstack/react-virtual` を使用したVirtualized Tree実装です。 +5000件以上の兄弟ページでも快適に動作するよう設計されています。 + +--- + +## 1. アーキテクチャ + +### 1.1 ディレクトリ構成 + +``` +src/features/page-tree/ +├── index.ts # メインエクスポート +├── components/ +│ ├── ItemsTree.tsx # コアvirtualizedツリーコンポーネント +│ ├── ItemsTree.spec.tsx # テスト +│ ├── TreeItemLayout.tsx # 汎用ツリーアイテムレイアウト +│ ├── TreeItemLayout.module.scss +│ ├── SimpleItemContent.tsx # シンプルなアイテムコンテンツ表示 +│ ├── SimpleItemContent.module.scss +│ ├── TreeNameInput.tsx # リネーム/新規作成用入力コンポーネント +│ ├── _tree-item-variables.scss # SCSS変数 +│ └── index.ts +├── hooks/ +│ ├── use-page-rename.tsx # Renameビジネスロジック +│ ├── use-page-create.tsx # Createビジネスロジック +│ ├── use-page-create.spec.tsx +│ ├── use-page-dnd.tsx # Drag & Dropビジネスロジック +│ ├── use-page-dnd.spec.ts +│ ├── use-page-dnd.module.scss # D&D用スタイル +│ ├── use-placeholder-rename-effect.ts # プレースホルダーリネームエフェクト +│ ├── use-socket-update-desc-count.ts # Socket.ioリアルタイム更新フック +│ ├── index.ts +│ └── _inner/ +│ ├── use-data-loader.ts # データローダーフック +│ ├── use-data-loader.spec.tsx +│ ├── use-data-loader.integration.spec.tsx +│ ├── use-scroll-to-selected-item.ts # スクロール制御フック +│ ├── use-tree-features.ts # Feature統合フック(checkbox・DnD含む) +│ ├── use-tree-revalidation.ts # ツリー再検証フック +│ ├── use-tree-item-handlers.tsx # アイテムハンドラーフック +│ ├── use-auto-expand-ancestors.ts # 祖先自動展開フック +│ ├── use-auto-expand-ancestors.spec.tsx +│ ├── use-expand-parent-on-create.ts # 作成時親展開フック +│ ├── use-checkbox.ts # チェックボックス状態フック +│ └── index.ts +├── interfaces/ +│ └── index.ts # TreeItemProps, TreeItemToolProps +├── states/ +│ ├── page-tree-update.ts # ツリー更新状態(Jotai) +│ ├── page-tree-desc-count-map.ts # 子孫カウント状態(Jotai) +│ ├── index.ts +│ └── _inner/ +│ ├── page-tree-create.ts # 作成中状態(Jotai) +│ ├── page-tree-create.spec.tsx +│ └── tree-rebuild.ts # ツリー再構築状態 +├── services/ +│ └── page-tree-children.ts # 子ページ取得サービス +└── constants/ + └── _inner.ts # ROOT_PAGE_VIRTUAL_ID +``` + +### 1.2 Sidebar専用コンポーネント(移動しなかったファイル) + +以下は `components/Sidebar/PageTreeItem/` に残留: + +- `PageTreeItem.tsx` - Sidebar専用の実装 +- `CountBadgeForPageTreeItem.tsx` - PageTree専用バッジ +- `use-page-item-control.tsx` - コンテキストメニュー制御 + +--- + +## 2. 主要コンポーネント + +### 2.1 ItemsTree + +**ファイル**: `features/page-tree/components/ItemsTree.tsx` + +Virtualizedツリーのコアコンポーネント。`@headless-tree/react` と `@tanstack/react-virtual` を統合。 + +#### Props + +```typescript +interface ItemsTreeProps { + // 表示対象のターゲットパスまたはID + targetPathOrId: string | null; + // WIPページを表示するか + isWipPageShown?: boolean; + // 仮想スクロール用の親要素 + scrollerElem: HTMLElement | null; + // カスタムTreeItemコンポーネント + CustomTreeItem?: React.ComponentType>; + // チェックボックス機能 + enableCheckboxes?: boolean; + initialCheckedItems?: string[]; + onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void; +} +``` + +#### 使用している @headless-tree/core Features + +- `asyncDataLoaderFeature` - 非同期データローディング +- `selectionFeature` - 選択機能 +- `renamingFeature` - リネーム機能 +- `hotkeysCoreFeature` - キーボードショートカット +- `checkboxesFeature` - チェックボックス(オプション) +- `dragAndDropFeature` - ドラッグ&ドロップ(オプション) + +#### 重要な実装詳細 + +1. **データローダー**: `use-data-loader.ts` で既存API(`/page-listing/root`, `/page-listing/children`)を活用 +2. **Virtualization**: `@tanstack/react-virtual` の `useVirtualizer` を使用、`overscan: 5` で最適化 +3. **初期スクロール**: `scrollToIndex` で選択アイテムまでスクロール + +### 2.2 TreeItemLayout + +**ファイル**: `features/page-tree/components/TreeItemLayout.tsx` + +汎用的なツリーアイテムレイアウト。展開/折りたたみ、アイコン、カスタムコンポーネントを配置。 + +#### Props + +```typescript +interface TreeItemLayoutProps { + page: IPageForTreeItem; + level: number; + isOpen: boolean; + isSelected: boolean; + onToggle?: () => void; + onClick?: () => void; + // カスタムコンポーネント + customEndComponents?: React.ReactNode[]; + customHoveredEndComponents?: React.ReactNode[]; + customAlternativeComponents?: React.ReactNode[]; + showAlternativeContent?: boolean; +} +``` + +#### 自動展開ロジック + +```typescript +useEffect(() => { + if (isExpanded) return; + const isPathToTarget = page.path != null + && targetPath.startsWith(addTrailingSlash(page.path)) + && targetPath !== page.path; + if (isPathToTarget) onToggle?.(); +}, [targetPath, page.path, isExpanded, onToggle]); +``` + +### 2.3 PageTreeItem + +**ファイル**: `components/Sidebar/PageTreeItem/PageTreeItem.tsx` + +Sidebar用のツリーアイテム実装。TreeItemLayoutを使用し、Rename/Create/Control機能を統合。 + +#### 機能 + +- WIPページフィルター +- descendantCountバッジ +- hover時の操作ボタン(duplicate/delete/rename/create) +- リネームモード表示 +- 新規作成入力表示(子として) + +--- + +## 3. 機能実装 + +### 3.1 Rename(ページ名変更) + +**実装ファイル**: +- `features/page-tree/hooks/use-page-rename.tsx` +- `features/page-tree/components/TreeNameInput.tsx` + +#### 使用方法 + +```typescript +const { rename, isRenaming, RenameAlternativeComponent } = usePageRename(item); + +// TreeItemLayoutに渡す + +``` + +#### 操作方法 + +- **開始**: F2キー or コンテキストメニュー +- **確定**: Enter +- **キャンセル**: Escape + +### 3.2 Create(ページ新規作成) + +**実装ファイル**: +- `features/page-tree/hooks/use-page-create.tsx` +- `features/page-tree/components/TreeNameInput.tsx` +- `features/page-tree/states/_inner/page-tree-create.ts` + +#### 状態管理(Jotai) + +```typescript +// page-tree-create.ts +creatingParentIdAtom: 作成中の親ノードID +useCreatingParentId(): 現在の作成中親ID取得 +useIsCreatingChild(parentId): 特定アイテムが作成中か判定 +usePageTreeCreateActions(): startCreating, cancelCreating +``` + +#### 使用方法 + +```typescript +const { isCreatingChild, CreateInputComponent, startCreating } = usePageCreate(item); + +// PageTreeItemで使用 +{isCreatingChild() && } +``` + +#### 操作方法 + +- **開始**: コンテキストメニューから「作成」を選択 +- **確定**: Enter → POST /page API → 新規ページに遷移 +- **キャンセル**: Escape or ブラー + +### 3.3 Drag and Drop(ページ移動) + +**実装ファイル**: +- `features/page-tree/hooks/use-page-dnd.tsx` +- `features/page-tree/hooks/use-page-dnd.module.scss` +- `features/page-tree/hooks/_inner/use-tree-features.ts` + +#### 機能概要 + +ページをドラッグ&ドロップして別のページの子として移動する機能。複数選択D&Dにも対応。 + +#### 使用方法 + +```typescript + +``` + +#### 主要コンポーネント + +- `usePageDnd(isEnabled)`: D&Dロジックを提供するフック(`UsePageDndProperties`を返す) + - `canDrag`: ドラッグ可否判定 + - `canDrop`: ドロップ可否判定 + - `onDrop`: ドロップ時の処理(APIコール、ツリー更新) + - `renderDragLine`: ドラッグライン描画(treeインスタンスを引数に取る) + +**統合方法**: +- `useTreeFeatures`が内部で`usePageDnd`を呼び出し、`dndProperties`として返す +- ItemsTree側で`dndProperties.renderDragLine(tree)`を呼び出してドラッグライン表示 + +#### バリデーションロジック + +**canDrag チェック項目**: +1. 祖先-子孫関係チェック: 選択されたアイテム間に祖先-子孫関係がある場合は禁止 +2. 保護ページチェック: `pagePathUtils.isUsersProtectedPages(path)`が`true`の場合は禁止 + +**canDrop チェック項目**: +1. ユーザートップページチェック: `pagePathUtils.isUsersTopPage(targetPath)`が`true`の場合は禁止 +2. 移動可否チェック: `pagePathUtils.canMoveByPath(fromPath, newPath)`で検証 + +#### エラーハンドリング + +- `operation__blocked`エラー: 「このページは現在移動できません」トースト表示 +- その他のエラー: 「ページの移動に失敗しました」トースト表示 + +#### ドロップ処理の流れ + +1. 移動APIコール: `/pages/rename`エンドポイントで各ページを新しいパスに移動 +2. SWRキャッシュ更新: `mutatePageTree()`でページツリーデータを再取得 +3. headless-tree更新: `notifyUpdateItems()`で親ノードの子リストを無効化 +4. ターゲット更新: `targetItem.invalidateItemData()`でdescendantCountを再取得 +5. 自動展開: `targetItem.expand()`でドロップ先を展開 + +#### 制限事項 + +- 並び替え(Reorder)は無効(子として追加のみ) +- キーボードD&Dは非対応 + +### 3.4 リアルタイム更新(Socket.io統合) + +**実装ファイル**: +- `features/page-tree/hooks/use-socket-update-desc-count.ts` +- `features/page-tree/states/page-tree-desc-count-map.ts` +- `features/page-tree/states/page-tree-update.ts` + +#### 設計方針 + +**descendantCountバッジの更新** と **ツリー構造の更新** は別々の関心事として分離: + +| 更新タイプ | トリガー | 動作 | 対象 | +|-----------|---------|------|------| +| バッジ更新 | Socket.io `UpdateDescCount` | 数字のみ更新(軽量) | 全祖先 | +| ツリー構造更新 | リロードボタン / 自分の操作後 | 子リスト再取得(重い) | 操作した本人のみ | + +**この分離の理由:** +- 大規模環境で多くのユーザーが同時に操作する場合、全員のツリーが頻繁に再構築されるとパフォーマンス問題が発生 +- バッジ(数字)の更新は軽量なので全員にリアルタイム反映してもOK +- ツリー構造の変更は操作した本人のウィンドウのみで即時反映し、他ユーザーはリロードボタンで対応 + +#### 使用方法 + +`ItemsTree`コンポーネント内で自動的に有効化されます。 + +```typescript +// ItemsTree.tsx内で呼び出し +useSocketUpdateDescCount(); +``` + +#### 受信イベント + +- `UpdateDescCount`: ページの子孫カウント(descendantCount)の更新 + - サーバーからページ作成/削除/移動時に発行される + - 受信データ(Record形式)をMap形式に変換してJotai stateに保存 + - **バッジ表示のみ更新、ツリー構造は更新しない** + +#### 実装詳細 + +```typescript +export const useSocketUpdateDescCount = (): void => { + const socket = useGlobalSocket(); + const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction(); + + useEffect(() => { + if (socket == null) return; + + const handler = (data: UpdateDescCountRawData) => { + // バッジの数字のみ更新(ツリー構造は更新しない) + const newData: UpdateDescCountData = new Map(Object.entries(data)); + updatePtDescCountMap(newData); + }; + + socket.on(SocketEventName.UpdateDescCount, handler); + return () => socket.off(SocketEventName.UpdateDescCount, handler); + }, [socket, updatePtDescCountMap]); +}; +``` + +#### ツリー構造の更新 + +ツリー構造(子リスト)の更新は以下のタイミングで行われる: + +1. **リロードボタン**: `notifyUpdateAllTrees()` を呼び出し、全ツリーを再取得 +2. **自分の操作後**: + - Create/Delete/Move操作の完了コールバックで `notifyUpdateItems([parentId])` を呼び出し + - 操作した親ノードの子リストのみ再取得 + +```typescript +// リロードボタンの例 +const { notifyUpdateAllTrees } = usePageTreeInformationUpdate(); +const handleReload = () => notifyUpdateAllTrees(); + +// 操作完了後の例(Create, Delete, Move) +const { notifyUpdateItems } = usePageTreeInformationUpdate(); +const handleOperationComplete = (parentId: string) => notifyUpdateItems([parentId]); +``` + +#### 関連状態 + +- `page-tree-desc-count-map.ts`: 子孫カウントを管理するJotai atom + - `usePageTreeDescCountMap()`: カウント取得(バッジ表示用) + - `usePageTreeDescCountMapAction()`: カウント更新(Socket.ioから) + +- `page-tree-update.ts`: ツリー更新を管理するJotai atom + - `generationAtom`: 更新世代番号 + - `lastUpdatedItemIdsAtom`: 更新対象アイテムID(nullは全体更新) + - `usePageTreeInformationUpdate()`: 更新通知(notifyUpdateItems, notifyUpdateAllTrees) + - `usePageTreeRevalidationEffect()`: 更新検知と再取得実行 + +### 3.5 Checkboxes(AI Assistant用) + +**使用箇所**: `AiAssistantManagementPageTreeSelection.tsx` + +ItemsTreeのcheckboxesオプションを使用。 + +#### Props + +```typescript + { + // チェック変更時の処理 + // ページパスに `/*` を付加して保存 + }} +/> +``` + +#### 実装詳細 + +**フック構成**: +- `useTreeFeatures`: feature設定とチェックボックス・D&D機能を統合管理 +- `useCheckbox`: チェックボックス状態管理(`checkedItemIds`, `setCheckedItems`, `createNotifyEffect`) +- `createNotifyEffect`: 親コンポーネントへの変更通知用ヘルパー関数を提供 + +**循環依存の回避**: +- `useTreeFeatures`はtreeインスタンスに依存しない +- `createNotifyEffect`がtreeインスタンスとコールバックを受け取り、useEffectのコールバック関数を返す +- ItemsTree側で`useEffect(createNotifyEffect(tree, onCheckedItemsChange), [createNotifyEffect, tree])`を呼び出す + +**設定**: +- `checkboxesFeature` を条件付きで追加 +- `propagateCheckedState: false` で子への伝播を無効化 +- `canCheckFolders: true` でフォルダもチェック可能 + +--- + +## 4. バックエンドAPI + +### 4.1 使用エンドポイント + +``` +GET /page-listing/root +→ ルートページ "/" のデータ + +GET /page-listing/children?id={pageId} +→ 指定ページの直下の子のみ + +GET /page-listing/item?id={pageId} +→ 単一ページデータ(新規追加) +``` + +### 4.2 IPageForTreeItem インターフェース + +```typescript +interface IPageForTreeItem { + _id: string; + path: string; + parent?: string; + descendantCount: number; + revision?: string; + grant: PageGrant; + isEmpty: boolean; + wip: boolean; + processData?: IPageOperationProcessData; +} +``` + +--- + +## 5. @headless-tree/react 基礎知識 + +### 5.1 データ構造 + +- **IDベースの参照**: ツリーアイテムは文字列IDで識別 +- **フラット構造を推奨**: dataLoaderで親子関係を定義 +- **ジェネリック型対応**: `useTree` でカスタム型を指定 + +### 5.2 非同期データローダー + +```typescript +const tree = useTree({ + rootItemId: "root", + dataLoader: { + getItem: async (itemId) => await api.fetchItem(itemId), + getChildren: async (itemId) => await api.fetchChildren(itemId), + }, + createLoadingItemData: () => ({ /* loading state */ }), + features: [asyncDataLoaderFeature], +}); +``` + +#### キャッシュの無効化 + +```typescript +const item = tree.getItemInstance("item1"); +item.invalidateItemData(); // アイテムデータの再取得 +item.invalidateChildrenIds(); // 子IDリストの再取得 +``` + +### 5.3 Virtualization統合 + +```typescript +const items = tree.getItems(); // フラット化されたアイテムリスト + +const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => 32, + overscan: 5, +}); +``` + +### 5.4 主要API + +#### Tree インスタンス +- `tree.getItems()`: フラット化されたツリーアイテムのリスト +- `tree.getItemInstance(id)`: IDからアイテムインスタンスを取得 +- `tree.getContainerProps()`: ツリーコンテナのprops(ホットキー有効化に必須) +- `tree.rebuildTree()`: ツリー構造を再構築 + +#### Item インスタンス +- `item.getProps()`: アイテム要素のprops +- `item.getId()`: アイテムID +- `item.getItemData()`: カスタムペイロード(IPageForTreeItem) +- `item.getItemMeta()`: メタデータ(level, indexなど) +- `item.isFolder()`: フォルダかどうか +- `item.isExpanded()`: 展開されているか +- `item.expand()` / `item.collapse()`: 展開/折りたたみ +- `item.startRenaming()`: リネームモード開始 +- `item.isRenaming()`: リネーム中か判定 + +--- + +## 6. パフォーマンス最適化 + +### 6.1 headless-tree のキャッシュ無効化と再取得 + +#### 重要な知見 + +`@headless-tree/core` の `asyncDataLoaderFeature` は内部キャッシュを持ち、`invalidateChildrenIds()` メソッドでキャッシュを無効化できます。 + +**invalidateChildrenIds(optimistic?: boolean) の動作:** + +```typescript +// 内部実装(feature.ts より) +invalidateChildrenIds: async ({ tree, itemId }, optimistic) => { + if (!optimistic) { + delete getDataRef(tree).current.childrenIds?.[itemId]; // キャッシュ削除 + } + await loadChildrenIds(tree, itemId); // データ再取得 + // loadChildrenIds 内で自動的に tree.rebuildTree() が呼ばれる +}; +``` + +**optimistic パラメータの影響:** + +| パラメータ | 動作 | 用途 | +|-----------|------|------| +| `false` (デフォルト) | ローディング状態を更新、再レンダリングをトリガー | 最後の呼び出しに使用 | +| `true` | ローディング状態を更新しない、古いデータを表示し続ける | バッチ処理の途中に使用 | + +**パフォーマンス最適化パターン:** + +```typescript +// ❌ 非効率: 全アイテムに optimistic=false +items.forEach(item => item.invalidateChildrenIds(false)); +// → 各呼び出しで rebuildTree() が実行され、N回の再構築が発生 + +// ✅ 効率的: 展開済みアイテムのみ対象、最後だけ optimistic=false +const expandedItems = tree.getItems().filter(item => item.isExpanded()); +expandedItems.forEach(item => item.invalidateChildrenIds(true)); // 楽観的 +rootItem.invalidateChildrenIds(false); // 最後に1回だけ再構築 +``` + +**実際の実装 (page-tree-update.ts):** + +```typescript +useEffect(() => { + if (globalGeneration <= generation) return; + + const shouldUpdateAll = globalLastUpdatedItemIds == null; + + if (shouldUpdateAll) { + // pendingリクエストキャッシュをクリア + invalidatePageTreeChildren(); + + // 展開済みアイテムのみ楽観的に無効化(rebuildTree回避) + const expandedItems = tree.getItems().filter(item => item.isExpanded()); + expandedItems.forEach(item => item.invalidateChildrenIds(true)); + + // ルートのみ optimistic=false で再構築トリガー + getItemInstance(ROOT_PAGE_VIRTUAL_ID)?.invalidateChildrenIds(false); + } else { + // 部分更新: 指定アイテムのみ + invalidatePageTreeChildren(globalLastUpdatedItemIds); + globalLastUpdatedItemIds.forEach(itemId => { + getItemInstance(itemId)?.invalidateChildrenIds(false); + }); + } + + onRevalidatedRef.current?.(); +}, [globalGeneration, generation, getItemInstance, globalLastUpdatedItemIds, tree]); +``` + +#### 注意事項 + +1. **invalidateChildrenIds は async 関数** - Promise を返すが、await しなくても動作する +2. **loadChildrenIds 完了後に自動で rebuildTree()** - 明示的な呼び出し不要 +3. **optimistic=true でもデータは再取得される** - ただしローディングUIは表示されない +4. **tree.getItems() は表示中のアイテムのみ** - 折りたたまれた子は含まれない + +### 6.2 Virtualization + +- **100k+アイテムでテスト済み** +- `overscan: 5` で表示範囲外の先読み +- `estimateSize: 32` でアイテム高さを推定 + +### 6.3 非同期データローダーのキャッシング + +- asyncDataLoaderFeatureが自動キャッシング +- 展開済みアイテムは再取得なし +- `invalidateChildrenIds()` で明示的に無効化可能 + +### 6.4 ツリー更新 + +```typescript +// Jotai atomでツリー更新を通知 +const { notifyUpdateItems } = usePageTreeInformationUpdate(); +notifyUpdateItems(updatedPages); + +// SWRでページデータを再取得 +const { mutate: mutatePageTree } = useSWRxPageTree(); +await mutatePageTree(); +``` + +--- + +## 7. 実装済み機能 + +- ✅ Virtualizedツリー表示 +- ✅ 展開/折りたたみ +- ✅ ページ遷移(クリック) +- ✅ 選択状態表示 +- ✅ WIPページフィルター +- ✅ descendantCountバッジ +- ✅ hover時の操作ボタン +- ✅ 選択ページまでの自動展開 +- ✅ 選択ページへの初期スクロール +- ✅ Rename(F2、コンテキストメニュー) +- ✅ Create(コンテキストメニュー) +- ✅ Duplicate(hover時ボタン) +- ✅ Delete(hover時ボタン) +- ✅ Checkboxes(AI Assistant用) +- ✅ Drag and Drop(ページ移動) +- ✅ リアルタイム更新(Socket.io統合) + +--- + +## 8. 未実装機能 + +なし(全機能実装済み) + +--- + +## 9. 参考リンク + +- @headless-tree/react 公式ドキュメント: https://headless-tree.lukasbach.com/ +- GitHub: https://github.com/lukasbach/headless-tree +- @tanstack/react-virtual: https://tanstack.com/virtual/latest + +--- + +## 10. 改修時の注意点 + +### 10.1 ホットキーサポート + +`hotkeysCoreFeature` と `getContainerProps()` の組み合わせが必須。 +`getContainerProps()` がないとホットキーが動作しない。 + +### 10.2 ツリー更新の通知 + +操作完了後は以下を呼び出す: +1. `mutatePageTree()` - SWRでデータ再取得 +2. `notifyUpdateItems()` - Jotai atomで更新通知 + +### 10.3 旧実装について + +以下のファイルはTypeScriptエラーあり(許容): +- `ItemsTree.tsx` - 旧実装 +- `PageTreeItem.tsx` - 旧Sidebar用 +- `TreeItemForModal.tsx` - 旧Modal用 + +--- + +## 更新履歴 + +- 2025-11-10: 初版作成(Virtualization計画) +- 2025-11-28: Rename/Create実装完了、ディレクトリ再編成 +- 2025-12-05: 仕様書として統合 +- 2025-12-08: Drag and Drop実装完了、ディレクトリ構成更新 +- 2025-12-08: リアルタイム更新(Socket.io統合)実装完了 +- 2025-12-08: headless-tree キャッシュ無効化の知見を追加(invalidateChildrenIds の optimistic パラメータ) +- 2025-12-08: Socket.io更新の設計方針を明確化(バッジ更新とツリー構造更新の分離) +- 2025-12-09: useTreeFeaturesリファクタリング完了(checkboxとDnD機能を統合、循環依存を回避) diff --git a/.serena/memories/apps-app-pagetree-performance-refactor-plan.md b/.serena/memories/apps-app-pagetree-performance-refactor-plan.md deleted file mode 100644 index c6e31497335..00000000000 --- a/.serena/memories/apps-app-pagetree-performance-refactor-plan.md +++ /dev/null @@ -1,186 +0,0 @@ -# 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({ - 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 ( -
    - {adaptedNodes.map(node => ( - - ))} -
-); -``` - -#### **Phase 2: Virtualization導入** - -**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張) -```typescript -const virtualizer = useVirtualizer({ - count: adaptedNodes.length, - getScrollElement: () => containerRef.current, - estimateSize: () => 40, -}); - -return ( -
-
- {virtualizer.getVirtualItems().map(virtualItem => { - const node = adaptedNodes[virtualItem.index]; - return ( -
- -
- ); - })} -
-
-); -``` - -#### **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をハイブリッド活用 \ No newline at end of file diff --git a/.serena/memories/apps-app-technical-specs.md b/.serena/memories/apps-app-technical-specs.md new file mode 100644 index 00000000000..2c29241f593 --- /dev/null +++ b/.serena/memories/apps-app-technical-specs.md @@ -0,0 +1,35 @@ +# apps/app 技術仕様 + +## ファイル構造・命名 +- Next.js: `*.page.tsx` +- テスト: `*.spec.ts`, `*.integ.ts` +- コンポーネント: `ComponentName.tsx` + +## API構造 +- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI準拠) +- **Features API**: `features/*/server/routes/` + +## 状態管理 +- **Jotai** (推奨): `states/` - アトミック分離 +- **SWR**: `stores/` - データフェッチ・キャッシュ + +## データベース +- **Mongoose**: `server/models/` (スキーマ定義) +- **Serializers**: `serializers/` (レスポンス変換) + +## セキュリティ・i18n +- **認証**: 複数プロバイダー + アクセストークン +- **XSS対策**: `services/general-xss-filter/` +- **i18n**: next-i18next (サーバー・クライアント両対応) + +## システム機能 +- **検索**: Elasticsearch統合 +- **監視**: OpenTelemetry (`features/opentelemetry/`) +- **プラグイン**: 動的読み込み (`features/growi-plugin/`) + +## 開発ガイドライン +1. 新機能は `features/` 実装 +2. TypeScript strict準拠 +3. Jotai状態管理優先 +4. API v3形式 +5. セキュリティ・i18n・テスト必須 \ No newline at end of file diff --git a/.serena/memories/development_environment.md b/.serena/memories/development_environment.md deleted file mode 100644 index 451b1017c53..00000000000 --- a/.serena/memories/development_environment.md +++ /dev/null @@ -1,45 +0,0 @@ -# 開発環境とツール - -## 推奨システム要件 -- **Node.js**: ^20 || ^22 -- **パッケージマネージャー**: pnpm 10.4.1 -- **OS**: Linux(Ubuntuベース)、macOS、Windows - -## 利用可能なLinuxコマンド -基本的なLinuxコマンドが利用可能: -- `apt`, `dpkg`: パッケージ管理 -- `git`: バージョン管理 -- `curl`, `wget`: HTTP クライアント -- `ssh`, `scp`, `rsync`: ネットワーク関連 -- `ps`, `lsof`, `netstat`, `top`: プロセス・ネットワーク監視 -- `tree`, `find`, `grep`: ファイル検索・操作 -- `zip`, `unzip`, `tar`, `gzip`, `bzip2`, `xz`: アーカイブ操作 - -## 開発用ブラウザ -```bash -# ローカルサーバーをブラウザで開く -"$BROWSER" http://localhost:3000 -``` - -## 環境変数管理 -- **dotenv-flow**: 環境ごとの設定管理 -- 環境ファイル: - - `.env.development`: 開発環境 - - `.env.production`: 本番環境 - - `.env.test`: テスト環境 - - `.env.*.local`: ローカル固有設定 - -## デバッグ -```bash -# デバッグモードでサーバー起動 -cd apps/app && pnpm run dev # --inspectフラグ付きでnodemon起動 - -# REPL(Read-Eval-Print Loop) -cd apps/app && pnpm run repl -``` - -## VS Code設定 -`.vscode/` ディレクトリに設定ファイルが含まれており、推奨拡張機能や設定が適用される。 - -## Docker対応 -各アプリケーションにDockerファイルが含まれており、コンテナベースでの開発も可能。 \ No newline at end of file diff --git a/.serena/memories/nextjs-pages-router-getLayout-pattern.md b/.serena/memories/nextjs-pages-router-getLayout-pattern.md new file mode 100644 index 00000000000..25471f34fc1 --- /dev/null +++ b/.serena/memories/nextjs-pages-router-getLayout-pattern.md @@ -0,0 +1,390 @@ +# Next.js Pages Router における getLayout パターン完全ガイド + +## getLayout パターンの基本概念と仕組み + +getLayout パターンは、Next.js Pages Router における**ページごとのレイアウト定義を可能にする強力なアーキテクチャパターン**です。このパターンを使用することで、各ページが独自のレイアウト階層を静的な `getLayout` 関数を通じて定義できます。 + +### 技術的な仕組み + +getLayout パターンは React のコンポーネントツリー構成を活用して動作します: + +```typescript +// pages/dashboard.tsx +import DashboardLayout from '../components/DashboardLayout' + +const Dashboard = () =>
ダッシュボードコンテンツ
+ +Dashboard.getLayout = function getLayout(page) { + return {page} +} + +export default Dashboard + +// pages/_app.tsx +export default function MyApp({ Component, pageProps }) { + const getLayout = Component.getLayout ?? ((page) => page) + return getLayout() +} +``` + +**動作原理:** +1. Next.js がページを初期化する際、`getLayout` プロパティをチェック +2. `getLayout` 関数がページコンポーネントを受け取り、完全なレイアウトツリーを返す +3. React の差分アルゴリズムがコンポーネントツリーの同じ位置を維持し、効率的な差分更新を実現 + +## パフォーマンス向上の具体的なメリット + +### レンダリング回数の削減 + +getLayout パターンの最大の利点は、**ページ遷移時のレイアウトコンポーネントの再マウント防止**です。React の差分アルゴリズムは、コンポーネントツリーの同じ位置に同じタイプのコンポーネントが存在する場合、そのインスタンスを再利用します。 + +**実測データ(Zenn.dev の事例):** +``` +実装前: +├ /_app 97.7 kB (全ページで Recoil を含む) +├ /articles 98 kB +├ /profile 98 kB + +実装後: +├ /_app 75 kB (22.7 kB 削減) +├ /articles 75.3 kB (最適化されたバンドル) +├ /profile 98.3 kB (必要な依存関係のみ) +``` + +### メモリ効率の改善 + +**主要な最適化ポイント:** +- **状態の永続化**: 入力値、スクロール位置、コンポーネント状態がナビゲーション間で保持 +- **イベントリスナーの永続性**: イベントハンドラーの再アタッチ回避 +- **DOM 参照の安定性**: サードパーティ統合用の DOM ノード参照の維持 + +## 実装のベストプラクティス + +### TypeScript での型安全な実装 + +```typescript +// types/layout.ts +import type { NextPage } from 'next' +import type { AppProps } from 'next/app' +import type { ReactElement, ReactNode } from 'react' + +export type NextPageWithLayout

= NextPage & { + getLayout?: (page: ReactElement) => ReactNode +} + +export type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout +} + +// pages/_app.tsx +import type { AppPropsWithLayout } from '../types/layout' + +export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { + const getLayout = Component.getLayout ?? ((page) => page) + return getLayout() +} +``` + +### ネストレイアウトの実装 + +```typescript +// utils/nestLayout.ts +export function nestLayout( + parentLayout: (page: ReactElement) => ReactNode, + childLayout: (page: ReactElement) => ReactNode +) { + return (page: ReactElement) => parentLayout(childLayout(page)) +} + +// pages/dashboard/profile.tsx +import { nestLayout } from '../../utils/nestLayout' +import { getLayout as getBaseLayout } from '../../components/BaseLayout' +import { getLayout as getDashboardLayout } from '../../components/DashboardLayout' + +const ProfilePage: NextPageWithLayout = () => { + return

プロフィールコンテンツ
+} + +ProfilePage.getLayout = nestLayout(getBaseLayout, getDashboardLayout) +``` + +### 状態管理の最適化 + +```typescript +// レイアウトごとのコンテキスト分割 +const AuthLayout = ({ children }) => ( + + + {children} + + +) + +const PublicLayout = ({ children }) => ( + + {children} + +) + +// 各ページで適切なレイアウトを選択 +Page.getLayout = (page) => {page} +``` + +## バッドプラクティスと実装時の落とし穴 + +### 避けるべきアンチパターン + +**❌ レイアウトの再作成** +```typescript +// 悪い例:レイアウトの永続性が失われる +const BadPage = () => { + return ( + +
ページコンテンツ
+
+ ) +} + +// ✅ 良い例:getLayout パターンを使用 +const GoodPage = () =>
ページコンテンツ
+GoodPage.getLayout = (page) => {page} +``` + +**❌ _app.tsx での条件付きレンダリング** +```typescript +// 悪い例:レイアウトの再マウントを引き起こす +function MyApp({ Component, pageProps, router }) { + if (router.pathname.startsWith('/dashboard')) { + return + } + return +} +``` + +### メモリリークの防止 + +```typescript +// ✅ 適切なクリーンアップ +const Layout = ({ children }) => { + useEffect(() => { + const handleResize = () => { /* 処理 */ } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + return
{children}
+} +``` + +## 他のレイアウト管理手法との比較 + +### Pages Router 内での比較 + +| 手法 | 複雑度 | パフォーマンス | 柔軟性 | 学習曲線 | +|------|--------|----------------|--------|----------| +| getLayout | 中 | 高 | 高 | 中 | +| HOCs | 高 | 中 | 高 | 高 | +| _app.js ルーティング | 低 | 高 | 低 | 低 | +| Context ベース | 高 | 中 | 高 | 高 | +| ラッパーコンポーネント | 低 | 低 | 低 | 低 | + +### Next.js 13+ App Router との比較 + +**App Router の利点:** +- ビルトインのレイアウトネスティング +- ファイルシステムベースの直感的な構造 +- 自動的な状態永続化 +- `loading.js` と `error.js` による組み込みの状態管理 + +**getLayout パターンの利点:** +- 明示的なレイアウト制御 +- 成熟した安定したパターン +- シンプルなメンタルモデル +- 優れた TTFB パフォーマンス + +**パフォーマンス比較:** +- **TTFB**: Pages Router が App Router より最大 2 倍高速 +- **開発サーバー**: Pages Router がより安定 +- **バンドルサイズ**: getLayout により選択的な読み込みが可能 + +## SEO と SSR/SSG への影響 + +### Core Web Vitals への影響 + +**測定された改善効果:** +- **LCP (Largest Contentful Paint)**: レイアウトの永続化により改善 +- **INP (Interaction to Next Paint)**: JavaScript 実行時間の削減 +- **CLS (Cumulative Layout Shift)**: レイアウトシフトの除去 + +**Netflix の事例:** +- Time-to-Interactive が **50% 削減** +- JavaScript バンドルサイズが **200KB 削減** +- デスクトップユーザーの 97% が高速な First Input Delay を体験 + +### SSR/SSG との統合 + +```typescript +// SSR との完全な互換性 +export async function getServerSideProps() { + const data = await fetchData() + return { props: { data } } +} + +function Page({ data }) { + return
{data.content}
+} + +Page.getLayout = (page) => {page} +``` + +## 実際のプロジェクトでの活用例 + +### 企業での実装事例 + +**Netflix:** +- ログアウト済みホームページで Time-to-Interactive を 50% 削減 +- 戦略的なプリフェッチで後続ページロードを 30% 改善 + +**Hulu:** +- Next.js による統一されたフロントエンドアーキテクチャ +- CSS-in-JS の自動コード分割を実装 + +**Sonos:** +- ビルド時間を **75% 短縮** +- パフォーマンススコアを **10% 改善** + +## パフォーマンス測定と最適化 + +### 測定ツールの設定 + +```javascript +// next.config.js - Bundle Analyzer の設定 +const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: process.env.ANALYZE === 'true', +}); + +module.exports = withBundleAnalyzer(nextConfig); + +// 使用方法 +// ANALYZE=true npm run build +``` + +### React DevTools Profiler の活用 + +```javascript +import { Profiler } from 'react'; + +function onRenderCallback(id, phase, actualDuration, baseDuration) { + console.log({ id, phase, actualDuration, baseDuration }); +} + + + {children} + +``` + +### 最適化テクニック + +**メモ化の実装:** +```typescript +import { memo, useMemo, useCallback } from 'react' + +const Layout = memo(({ children, menuItems }) => { + const processedMenu = useMemo(() => + menuItems.filter(item => item.visible).sort(), + [menuItems] + ); + + const handleNavigation = useCallback((path) => { + router.push(path); + }, [router]); + + return ( +
+ + {children} +
+ ); +}); +``` + +**動的インポートによるコード分割:** +```typescript +import dynamic from 'next/dynamic'; + +const DynamicSidebar = dynamic(() => import('../components/Sidebar'), { + loading: () => , + ssr: false +}); + +const Layout = ({ children }) => ( +
+
+ +
{children}
+
+); +``` + +### パフォーマンスバジェットの実装 + +```javascript +export const PERFORMANCE_BUDGETS = { + layoutRenderTime: 16, // 60fps のための 16ms + memoryUsage: 50 * 1024 * 1024, // 50MB + bundleSize: 200 * 1024, // 200KB + firstContentfulPaint: 2000, // 2秒 +}; + +const measureLayoutPerformance = (layoutName, renderFn) => { + const start = performance.now(); + const result = renderFn(); + const duration = performance.now() - start; + + if (duration > PERFORMANCE_BUDGETS.layoutRenderTime) { + console.warn(`Layout ${layoutName} がレンダーバジェットを超過: ${duration}ms`); + } + + return result; +}; +``` + +## 実装チェックリスト + +### 初期設定 +- [ ] TypeScript の型定義を設定 +- [ ] `_app.tsx` に getLayout パターンを実装 +- [ ] React DevTools をインストール +- [ ] Bundle Analyzer を設定 + +### 最適化の優先順位 + +**高影響・低労力:** +- [ ] レイアウトコンポーネントに React.memo を実装 +- [ ] Bundle Analyzer で大きな依存関係を特定 +- [ ] Context Provider をレイアウトごとに分割 + +**中影響・中労力:** +- [ ] 非クリティカルなレイアウトコンポーネントに動的インポートを実装 +- [ ] Suspense 境界を追加してストリーミングを改善 +- [ ] 自動パフォーマンス監視を設定 + +**高影響・高労力:** +- [ ] 状態管理アーキテクチャの再設計 +- [ ] 包括的なプログレッシブエンハンスメントの実装 +- [ ] 高度なパフォーマンスバジェットシステムの作成 + +## まとめ + +getLayout パターンは、Next.js Pages Router において**強力なパフォーマンス最適化とアーキテクチャの柔軟性**を提供します。適切に実装すれば、以下の利点が得られます: + +1. **パフォーマンスの向上**: 不要な再レンダリングの削減とバンドルサイズの最適化 +2. **ユーザー体験の向上**: 状態の永続化とスムーズなページ遷移 +3. **アーキテクチャの柔軟性**: ページごとのレイアウトカスタマイズとパフォーマンスの維持 +4. **メモリ効率**: コンポーネントの再利用による最適なリソース使用 + +App Router が新しい代替手段を提供する一方で、getLayout パターンの理解は React のレンダリング最適化とコンポーネントライフサイクル管理への深い洞察を提供します。Pages Router アプリケーションでは、プロジェクトの開始時から getLayout を実装することで、アプリケーションのスケールに応じて最大限のパフォーマンス利点とアーキテクチャの柔軟性を維持できます。 \ No newline at end of file diff --git a/.serena/memories/page-state-hooks-useLatestRevision-degradation.md b/.serena/memories/page-state-hooks-useLatestRevision-degradation.md new file mode 100644 index 00000000000..97bf5abe8a1 --- /dev/null +++ b/.serena/memories/page-state-hooks-useLatestRevision-degradation.md @@ -0,0 +1,440 @@ +# Page State Hooks - useLatestRevision リファクタリング記録 + +**Date**: 2025-10-31 +**Branch**: support/use-jotai + +## 🎯 実施内容のサマリー + +`support/use-jotai` ブランチで `useLatestRevision` が機能していなかった問題を解決し、リビジョン管理の状態管理を大幅に改善しました。 + +### 主な成果 + +1. ✅ `IPageInfoForEntity.latestRevisionId` を導入 +2. ✅ `useSWRxIsLatestRevision` を SWR ベースで実装(Jotai atom から脱却) +3. ✅ `remoteRevisionIdAtom` を完全削除(状態管理の簡素化) +4. ✅ `useIsRevisionOutdated` の意味論を改善(「意図的な過去閲覧」を考慮) +5. ✅ `useRevisionIdFromUrl` で URL パラメータ取得を一元化 + +--- + +## 📋 実装の要点 + +### 1. `IPageInfoForEntity` に `latestRevisionId` を追加 + +**ファイル**: `packages/core/src/interfaces/page.ts` + +```typescript +export type IPageInfoForEntity = Omit & { + // ... existing fields + latestRevisionId?: string; // ✅ 追加 +}; +``` + +**ファイル**: `apps/app/src/server/service/page/index.ts:2605` + +```typescript +const infoForEntity: Omit = { + // ... existing fields + latestRevisionId: page.revision != null ? getIdStringForRef(page.revision) : undefined, +}; +``` + +**データフロー**: SSR で `constructBasicPageInfo` が自動的に `latestRevisionId` を設定 → `useSWRxPageInfo` で参照 + +--- + +### 2. `useSWRxIsLatestRevision` を SWR ベースで実装 + +**ファイル**: `stores/page.tsx:164-191` + +```typescript +export const useSWRxIsLatestRevision = (): SWRResponse => { + const currentPage = useCurrentPageData(); + const pageId = currentPage?._id; + const shareLinkId = useShareLinkId(); + const { data: pageInfo } = useSWRxPageInfo(pageId, shareLinkId); + + const latestRevisionId = pageInfo && 'latestRevisionId' in pageInfo + ? pageInfo.latestRevisionId + : undefined; + + const key = useMemo(() => { + if (currentPage?.revision?._id == null) { + return null; + } + return ['isLatestRevision', currentPage.revision._id, latestRevisionId ?? null]; + }, [currentPage?.revision?._id, latestRevisionId]); + + return useSWRImmutable( + key, + ([, currentRevisionId, latestRevisionId]) => { + if (latestRevisionId == null) { + return true; // Assume latest if not available + } + return latestRevisionId === currentRevisionId; + }, + ); +}; +``` + +**使用箇所**: OldRevisionAlert, DisplaySwitcher, PageEditorReadOnly + +**判定**: `.data !== false` で「古いリビジョン」を検出 + +--- + +### 3. `remoteRevisionIdAtom` の完全削除 + +**削除理由**: +- `useSWRxPageInfo.data.latestRevisionId` で代替可能 +- 「Socket.io 更新検知」と「最新リビジョン保持」の用途が混在していた +- 状態管理が複雑化していた + +**重要**: `RemoteRevisionData.remoteRevisionId` は型定義に残した +→ コンフリクト解決時に「どのリビジョンに対して保存するか」の情報として必要 + +--- + +### 4. `useIsRevisionOutdated` の意味論的改善 + +**改善前**: 単純に「現在のリビジョン ≠ 最新リビジョン」を判定 +**問題**: URL `?revisionId=xxx` で意図的に過去を見ている場合も `true` を返していた + +**改善後**: 「ユーザーが意図的に過去リビジョンを見ているか」を考慮 + +**ファイル**: `states/context.ts:82-100` + +```typescript +export const useRevisionIdFromUrl = (): string | undefined => { + const router = useRouter(); + const revisionId = router.query.revisionId; + return typeof revisionId === 'string' ? revisionId : undefined; +}; + +export const useIsViewingSpecificRevision = (): boolean => { + const revisionId = useRevisionIdFromUrl(); + return revisionId != null; +}; +``` + +**ファイル**: `stores/page.tsx:193-219` + +```typescript +export const useIsRevisionOutdated = (): boolean => { + const { data: isLatestRevision } = useSWRxIsLatestRevision(); + const isViewingSpecificRevision = useIsViewingSpecificRevision(); + + // If user intentionally views a specific revision, don't show "outdated" alert + if (isViewingSpecificRevision) { + return false; + } + + if (isLatestRevision == null) { + return false; + } + + // User expects latest, but it's not latest = outdated + return !isLatestRevision; +}; +``` + +--- + +## 🎭 動作例 + +| 状況 | isLatestRevision | isViewingSpecificRevision | isRevisionOutdated | 意味 | +|------|------------------|---------------------------|---------------------|------| +| 最新を表示中 | true | false | false | 正常 | +| Socket.io更新を受信 | false | false | **true** | 「再fetchせよ」 | +| URL `?revisionId=old` で過去を閲覧 | false | true | false | 「意図的な過去閲覧」 | + +--- + +## 🔄 現状の remoteRevision 系 atom と useSetRemoteLatestPageData + +### 削除済み +- ✅ `remoteRevisionIdAtom` - 完全削除(`useSWRxPageInfo.data.latestRevisionId` で代替) + +### 残存している atom(未整理) +- ⚠️ `remoteRevisionBodyAtom` - ConflictDiffModal で使用 +- ⚠️ `remoteRevisionLastUpdateUserAtom` - ConflictDiffModal, PageStatusAlert で使用 +- ⚠️ `remoteRevisionLastUpdatedAtAtom` - ConflictDiffModal で使用 + +### `useSetRemoteLatestPageData` の役割 + +**定義**: `states/page/use-set-remote-latest-page-data.ts` + +```typescript +export type RemoteRevisionData = { + remoteRevisionId: string; // 型には含むが atom には保存しない + remoteRevisionBody: string; + remoteRevisionLastUpdateUser?: IUserHasId; + remoteRevisionLastUpdatedAt: Date; +}; + +export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => { + // remoteRevisionBodyAtom, remoteRevisionLastUpdateUserAtom, remoteRevisionLastUpdatedAtAtom を更新 + // remoteRevisionId は atom に保存しない(コンフリクト解決時のパラメータとしてのみ使用) +}; +``` + +**使用箇所**(6箇所): + +1. **`page-updated.ts`** - Socket.io でページ更新受信時 + ```typescript + // 他のユーザーがページを更新したときに最新リビジョン情報を保存 + setRemoteLatestPageData({ + remoteRevisionId: s2cMessagePageUpdated.revisionId, + remoteRevisionBody: s2cMessagePageUpdated.revisionBody, + remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser, + remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt, + }); + ``` + +2. **`page-operation.ts`** - 自分がページ保存した後(`useUpdateStateAfterSave`) + ```typescript + // 自分が保存した後の最新リビジョン情報を保存 + setRemoteLatestPageData({ + remoteRevisionId: updatedPage.revision._id, + remoteRevisionBody: updatedPage.revision.body, + remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser, + remoteRevisionLastUpdatedAt: updatedPage.updatedAt, + }); + ``` + +3. **`conflict.tsx`** - コンフリクト解決時(`useConflictResolver`) + ```typescript + // コンフリクト発生時にリモートリビジョン情報を保存 + setRemoteLatestPageData(remoteRevidsionData); + ``` + +4. **`drawio-modal-launcher-for-view.ts`** - Drawio 編集でコンフリクト発生時 +5. **`handsontable-modal-launcher-for-view.ts`** - Handsontable 編集でコンフリクト発生時 +6. **定義ファイル自体** + +### 現在のデータフロー + +``` +┌─────────────────────────────────────────────────────┐ +│ Socket.io / 保存処理 / コンフリクト │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ useSetRemoteLatestPageData │ +│ ├─ remoteRevisionBodyAtom ← body │ +│ ├─ remoteRevisionLastUpdateUserAtom ← user │ +│ └─ remoteRevisionLastUpdatedAtAtom ← date │ +│ (remoteRevisionId は保存しない) │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ 使用箇所 │ +│ ├─ ConflictDiffModal: body, user, date を表示 │ +│ └─ PageStatusAlert: user を表示 │ +└─────────────────────────────────────────────────────┘ +``` + +### 問題点 + +1. **PageInfo (latestRevisionId) との同期がない**: + - Socket.io 更新時に `remoteRevision*` atom は更新される + - しかし `useSWRxPageInfo.data.latestRevisionId` は更新されない + - → `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` がリアルタイム更新を検知できない + +2. **用途が限定的**: + - 主に ConflictDiffModal でリモートリビジョンの詳細を表示するために使用 + - PageStatusAlert でも使用しているが、本来は `useIsRevisionOutdated()` で十分 + +3. **データの二重管理**: + - リビジョン ID: `useSWRxPageInfo.data.latestRevisionId` で管理 + - リビジョン詳細 (body, user, date): atom で管理 + - 一貫性のないデータ管理 + +--- + +## 🎯 次に取り組むべきタスク + +### PageInfo (useSWRxPageInfo) の mutate が必要な3つのタイミング + +#### 1. 🔴 SSR時の optimistic update + +**問題**: +- SSR で `pageWithMeta.meta` (IPageInfoForEntity) が取得されているが、`useSWRxPageInfo` のキャッシュに入っていない +- クライアント初回レンダリング時に PageInfo が未取得状態になる + +**実装方針**: +```typescript +// [[...path]]/index.page.tsx または適切な場所 +const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId); + +useEffect(() => { + if (pageWithMeta?.meta) { + mutatePageInfo(pageWithMeta.meta, { revalidate: false }); + } +}, [pageWithMeta?.meta, mutatePageInfo]); +``` + +**Note**: +- Jotai の hydrate とは別レイヤー(Jotai は atom、これは SWR のキャッシュ) +- `useSWRxPageInfo` は既に `initialData` パラメータを持っているが、呼び出し側で渡していない +- **重要**: `mutatePageInfo` は bound mutate(hook から返されるもの)を使う + +--- + +#### 2. 🔴 same route 遷移時の mutate + +**問題**: +- `[[...path]]` ルート内での遷移(例: `/pageA` → `/pageB`)時に PageInfo が更新されない +- `useFetchCurrentPage` が新しいページを取得しても PageInfo は古いまま + +**実装方針**: +```typescript +// states/page/use-fetch-current-page.ts +export const useFetchCurrentPage = () => { + const shareLinkId = useAtomValue(shareLinkIdAtom); + const revisionIdFromUrl = useRevisionIdFromUrl(); + + // ✅ 追加: PageInfo の mutate 関数を取得 + const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPageId, shareLinkId); + + const fetchCurrentPage = useAtomCallback( + useCallback(async (get, set, args) => { + // ... 既存のフェッチ処理 ... + + const { data } = await apiv3Get('/page', params); + const { page: newData } = data; + + set(currentPageDataAtom, newData); + set(currentPageIdAtom, newData._id); + + // ✅ 追加: PageInfo を再フェッチ + mutatePageInfo(); // 引数なし = revalidate (再フェッチ) + + return newData; + }, [shareLinkId, revisionIdFromUrl, mutatePageInfo]) + ); +}; +``` + +**Note**: +- `mutatePageInfo()` を引数なしで呼ぶと SWR が再フェッチする +- `/page` API からは meta が取得できないため、再フェッチが必要 + +--- + +#### 3. 🔴 Socket.io 更新時の mutate + +**問題**: +- Socket.io で他のユーザーがページを更新したとき、`useSWRxPageInfo` のキャッシュが更新されない +- `latestRevisionId` が古いままになる +- **重要**: `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` が正しく動作しない + +**実装方針**: +```typescript +// client/services/side-effects/page-updated.ts +const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id, shareLinkId); + +const remotePageDataUpdateHandler = useCallback((data) => { + const { s2cMessagePageUpdated } = data; + + // 既存: remoteRevision atom を更新 + setRemoteLatestPageData(remoteData); + + // ✅ 追加: PageInfo の latestRevisionId を optimistic update + if (currentPage?._id != null) { + mutatePageInfo((currentPageInfo) => { + if (currentPageInfo && 'latestRevisionId' in currentPageInfo) { + return { + ...currentPageInfo, + latestRevisionId: s2cMessagePageUpdated.revisionId, + }; + } + return currentPageInfo; + }, { revalidate: false }); + } +}, [currentPage?._id, mutatePageInfo, setRemoteLatestPageData]); +``` + +**Note**: +- 引数に updater 関数を渡して既存データを部分更新 +- `revalidate: false` で再フェッチを抑制(optimistic update のみ) + +--- + +### SWR の mutate の仕組み + +**Bound mutate** (推奨): +```typescript +const { data, mutate } = useSWRxPageInfo(pageId, shareLinkId); +mutate(newData, options); // 自動的に key に紐付いている +``` + +**グローバル mutate**: +```typescript +import { mutate } from 'swr'; +mutate(['/page/info', pageId, shareLinkId, isGuestUser], newData, options); +``` + +**optimistic update のオプション**: +- `{ revalidate: false }` - 再フェッチせず、キャッシュのみ更新 +- `mutate()` (引数なし) - 再フェッチ +- `mutate(updater, options)` - updater 関数で部分更新 + +--- + +### 🟡 優先度 中: PageStatusAlert の重複ロジック削除 + +**ファイル**: `src/client/components/PageStatusAlert.tsx` + +**現状**: 独自に `isRevisionOutdated` を計算している +**提案**: `useIsRevisionOutdated()` を使用 + +--- + +### 🟢 優先度 低 + +- テストコードの更新 +- `initLatestRevisionField` の役割ドキュメント化 + +--- + +## 📊 アーキテクチャの改善 + +### Before (問題のある状態) + +``` +┌─────────────────────┐ +│ latestRevisionAtom │ ← atom(true) でハードコード(機能せず) +└─────────────────────┘ +┌─────────────────────┐ +│ remoteRevisionIdAtom│ ← 複数の用途で混在(Socket.io更新 + 最新リビジョン保持) +└─────────────────────┘ +``` + +### After (改善後) + +``` +┌──────────────────────────────┐ +│ useSWRxPageInfo │ +│ └─ data.latestRevisionId │ ← SSR で自動設定、SWR でキャッシュ管理 +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ useSWRxIsLatestRevision() │ ← SWR ベース、汎用的な状態確認 +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ useIsRevisionOutdated() │ ← 「再fetch推奨」のメッセージ性 +│ + useIsViewingSpecificRevision│ ← URL パラメータを考慮 +└──────────────────────────────┘ +``` + +--- + +## ✅ メリット + +1. **状態管理の簡素化**: Jotai atom を削減、SWR の既存インフラを活用 +2. **データフローの明確化**: SSR → SWR → hooks という一貫した流れ +3. **意味論の改善**: `useIsRevisionOutdated` が「再fetch推奨」を正確に表現 +4. **保守性の向上**: URL パラメータ取得を `useRevisionIdFromUrl` に集約 +5. **型安全性**: `IPageInfoForEntity` で厳密に型付け diff --git a/.serena/memories/page-transition-and-rendering-flow.md b/.serena/memories/page-transition-and-rendering-flow.md new file mode 100644 index 00000000000..5c2c8be347b --- /dev/null +++ b/.serena/memories/page-transition-and-rendering-flow.md @@ -0,0 +1,65 @@ +# ページ遷移とレンダリングのデータフロー + +このドキュメントは、GROWIのページ遷移からレンダリングまでのデータフローを解説します。 + +## 登場人物 + +1. **`[[...path]].page.tsx`**: Next.js の動的ルーティングを担うメインコンポーネント。サーバーサイドとクライアントサイドの両方で動作します。 +2. **`useSameRouteNavigation.ts`**: クライアントサイドでのパス変更を検知し、データ取得を**トリガー**するフック。 +3. **`useFetchCurrentPage.ts`**: データ取得と関連する Jotai atom の更新を一元管理するフック。データ取得が本当に必要かどうかの最終判断も担います。 +4. **`useShallowRouting.ts`**: サーバーサイドで正規化されたパスとブラウザのURLを同期させるフック。 +5. **`server-side-props.ts`**: サーバーサイドレンダリング(SSR)時にページデータを取得し、`props` としてページコンポーネントに渡します。 + +--- + +## フロー1: サーバーサイドレンダリング(初回アクセス時) + +ユーザーがURLに直接アクセスするか、ページをリロードした際のフローです。 + +1. **リクエスト受信**: サーバーがユーザーからのリクエスト(例: `/user/username/memo`)を受け取ります。 +2. **`getServerSideProps` の実行**: + - `server-side-props.ts` の `getServerSidePropsForInitial` が実行されます。 + - `retrievePageData` が呼び出され、パスの正規化(例: `/user/username` → `/user/username/`)が行われ、APIからページデータを取得します。 + - 取得したデータと、正規化後のパス (`currentPathname`) を `props` として `[[...path]].page.tsx` に渡します。 +3. **コンポーネントのレンダリングとJotai Atomの初期化**: + - `[[...path]].page.tsx` は `props` を受け取り、そのデータで `currentPageDataAtom` などのJotai Atomを初期化します。 + - `PageView` などのコンポーネントがサーバーサイドでレンダリングされます。 +4. **クライアントサイドでのハイドレーションとURL正規化**: + - レンダリングされたHTMLがブラウザに送信され、Reactがハイドレーションを行います。 + - **`useShallowRouting`** が実行され、ブラウザのURL (`/user/username/memo`) と `props.currentPathname` (`/user/username/memo/`) を比較します。 + - 差異がある場合、`router.replace` を `shallow: true` で実行し、ブラウザのURLをサーバーが認識している正規化後のパスに静かに更新します。 + +--- + +## フロー2: クライアントサイドナビゲーション(`` クリック時) + +アプリケーション内でページ間を移動する際のフローです。 + +1. **ナビゲーション開始**: + - ユーザーが `` をクリックします。 + - Next.js の `useRouter` がURLの変更を検出し、`[[...path]].page.tsx` が再評価されます。 +2. **`useSameRouteNavigation` によるトリガー**: + - このフックの `useEffect` が `router.asPath` の変更 (`/new/page`) を検知します。 + - **`fetchCurrentPage({ path: '/new/page' })`** を呼び出します。このフックは常にデータ取得を試みます。 +3. **`useFetchCurrentPage` によるデータ取得の判断と実行**: + - `fetchCurrentPage` 関数が実行されます。 + - **3a. パスの前処理**: + - まず、引数で渡された `path` をデコードします(例: `encoded%2Fpath` → `encoded/path`)。 + - 次に、パスがパーマリンク形式(例: `/65d4e0a0f7b7b2e5a8652e86`)かどうかを判定します。 + - **3b. 重複取得の防止(ガード節)**: + - 前処理したパスや、パーマリンクから抽出したページIDが、現在Jotaiで管理されているページのパスやIDと同じでないかチェックします。 + - 同じであれば、APIを叩かずに処理を中断し、現在のページデータを返します。 + - **3c. 読み込み状態開始**: `pageLoadingAtom` を `true` に設定します。 + - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。 +4. **アトミックな状態更新**: + - **API成功時**: + - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。 + - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。 + - **APIエラー時 (例: 404 Not Found)**: + - `pageErrorAtom` にエラーオブジェクトを設定します。 + - `pageNotFoundAtom` を `true` に設定します。 + - 最後に `pageLoadingAtom` を `false` に設定します。 +5. **`PageView` の最終レンダリング**: + - `currentPageDataAtom` の更新がトリガーとなり、`PageView` コンポーネントが新しいデータで再レンダリングされます。 +6. **副作用の実行**: + - `useSameRouteNavigation` 内で `fetchCurrentPage` が完了した後、`mutateEditingMarkdown` が呼び出され、エディタの状態が更新されます。 diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md deleted file mode 100644 index 95df11b46a6..00000000000 --- a/.serena/memories/suggested_commands.md +++ /dev/null @@ -1,100 +0,0 @@ -# 推奨開発コマンド集 - -## セットアップ -```bash -# 初期セットアップ -pnpm run bootstrap -# または -pnpm install -``` - -## 開発サーバー -```bash -# メインアプリケーション開発モード -cd /workspace/growi/apps/app && pnpm run dev - -# ルートから起動(本番用ビルド後) -pnpm start -``` - -## ビルド -```bash -# メインアプリケーションのビルド -pnpm run app:build - -# Slackbot Proxyのビルド -pnpm run slackbot-proxy:build - -# 全体ビルド(Turboで並列実行) -turbo run build -``` - -## Lint・フォーマット -```bash -# 全てのLint実行 -pnpm run lint -``` - -## apps/app の Lint・フォーマット -```bash -# 【推奨】Biome実行(lint + format) -cd /workspace/growi/apps/app pnpm run lint:biome - -# 【過渡期】ESLint実行(廃止予定) -cd /workspace/growi/apps/app pnpm run lint:eslint - -# Stylelint実行 -cd /workspace/growi/apps/app pnpm run lint:styles - -# 全てのLint実行 -cd /workspace/growi/apps/app pnpm run lint - -# TypeScript型チェック -cd /workspace/growi/apps/app pnpm run lint:typecheck -``` - -## テスト -```bash -# 【推奨】Vitestテスト実行 -pnpm run test:vitest - -# 【過渡期】Jest(統合テスト)(廃止予定) -pnpm run test:jest - -# 全てのテスト実行(過渡期対応) -pnpm run test - -# Vitestで特定のファイルに絞って実行 -pnpm run test:vitest {target-file-name} - -# E2Eテスト(Playwright) -npx playwright test -``` - -## データベース関連 -```bash -# マイグレーション実行 -cd apps/app && pnpm run migrate - -# 開発環境でのマイグレーション -cd apps/app && pnpm run dev:migrate - -# マイグレーション状態確認 -cd apps/app && pnpm run dev:migrate:status -``` - -## その他の便利コマンド -```bash -# REPL起動 -cd apps/app && pnpm run repl - -# OpenAPI仕様生成 -cd apps/app && pnpm run openapi:generate-spec:apiv3 - -# クリーンアップ -cd apps/app && pnpm run clean -``` - -## 注意事項 -- ESLintとJestは廃止予定のため、新規開発ではBiomeとVitestを使用してください -- 既存のコードは段階的に移行中です \ No newline at end of file diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md index cb0bf3933ba..ac8a2eacd10 100644 --- a/.serena/memories/tech_stack.md +++ b/.serena/memories/tech_stack.md @@ -1,42 +1,41 @@ -# 技術スタック - -## プログラミング言語 -- **TypeScript**: メイン言語(~5.0.0) -- **JavaScript**: 一部のコンポーネント - -## フロントエンド -- **Next.js**: Reactベースのフレームワーク -- **React**: UIライブラリ -- **Vite**: ビルドツール、開発サーバー -- **SCSS**: スタイルシート -- **SWR**: グローバルステート管理、データフェッチ・キャッシュ管理(^2.3.2) - -## バックエンド -- **Node.js**: ランタイム(^20 || ^22) -- **Express.js**: Webフレームワーク(推測) -- **MongoDB**: データベース -- **Mongoose**: MongoDB用ORM(^6.13.6) - - mongoose-gridfs: GridFS対応(^1.2.42) - - mongoose-paginate-v2: ページネーション(^1.3.9) - - mongoose-unique-validator: バリデーション(^2.0.3) - -## 開発ツール -- **pnpm**: パッケージマネージャー(10.4.1) -- **Turbo**: モノレポビルドシステム(^2.1.3) -- **ESLint**: Linter(weseek設定を使用)【廃止予定 - 現在は過渡期】 -- **Biome**: 統一予定のLinter/Formatter -- **Stylelint**: CSS/SCSSのLinter -- **Jest**: テスティングフレームワーク【廃止予定 - 現在は過渡期】 -- **Vitest**: 高速テスティングフレームワーク【統一予定】 -- **Playwright**: E2Eテスト【統一予定】 - -## その他のツール -- **SWC**: TypeScriptコンパイラー(高速) -- **ts-node**: TypeScript直接実行 -- **nodemon**: 開発時のホットリロード -- **dotenv-flow**: 環境変数管理 -- **Swagger/OpenAPI**: API仕様 - -## 移行計画 -- **Linter**: ESLint → Biome に統一予定 -- **テスト**: Jest → Vitest + Playwright に統一予定 \ No newline at end of file +# 技術スタック & 開発環境 + +## コア技術 +- **TypeScript** ~5.0.0 + **Next.js** (React) +- **Node.js** ^20||^22 + **MongoDB** + **Mongoose** ^6.13.6 +- **pnpm** 10.4.1 + **Turbo** ^2.1.3 (モノレポ) + +## 状態管理・データ +- **Jotai**: アトミック状態管理(推奨) +- **SWR** ^2.3.2: データフェッチ・キャッシュ + +## 開発ツール移行状況 +| 従来 | 移行先 | 状況 | +|------|--------|------| +| ESLint | **Biome** | 新規推奨 | +| Jest | **Vitest** + **Playwright** | 新規推奨 | + +## 主要コマンド +```bash +# 開発 +cd apps/app && pnpm run dev + +# 品質チェック +pnpm run lint:biome # 新規推奨 +pnpm run lint:typecheck # 型チェック正式コマンド +pnpm run test:vitest # 新規推奨 + +# ビルド +pnpm run app:build +turbo run build # 並列ビルド +``` + +## ファイル命名規則 +- Next.js: `*.page.tsx` +- テスト: `*.spec.ts` (Vitest), `*.integ.ts` +- コンポーネント: `ComponentName.tsx` + +## API・アーキテクチャ +- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI) +- **Features**: `features/*/` (機能別分離) +- **SCSS**: CSS Modules使用 \ No newline at end of file diff --git a/.serena/memories/vitest-testing-tips-and-best-practices.md b/.serena/memories/vitest-testing-tips-and-best-practices.md new file mode 100644 index 00000000000..f7d35c8c268 --- /dev/null +++ b/.serena/memories/vitest-testing-tips-and-best-practices.md @@ -0,0 +1,95 @@ +# Vitest + TypeScript Testing Guide + +## 核心技術要素 + +### tsconfig.json最適設定 +```json +{ + "compilerOptions": { + "types": ["vitest/globals"] // グローバルAPI: describe, it, expect等をインポート不要化 + } +} +``` + +### vitest-mock-extended: 型安全モッキング +```typescript +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +// 完全型安全なNext.js Routerモック +const mockRouter: DeepMockProxy = mockDeep(); +mockRouter.asPath = '/test-path'; // TypeScript補完・型チェック有効 + +// 複雑なUnion型も完全サポート +interface ComplexProps { + currentPageId?: string | null; + currentPathname?: string | null; +} +const mockProps: DeepMockProxy = mockDeep(); +``` + +### React Testing Library + Jotai統合 +```typescript +const renderWithProvider = (ui: React.ReactElement, scope?: Scope) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return render(ui, { wrapper: Wrapper }); +}; +``` + +## 実践パターン + +### 非同期テスト +```typescript +import { waitFor, act } from '@testing-library/react'; + +await act(async () => { + result.current.triggerAsyncAction(); +}); + +await waitFor(() => { + expect(result.current.isLoading).toBe(false); +}); +``` + +### 詳細アサーション +```typescript +expect(mockFunction).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: '/expected-path', + data: expect.any(Object) + }) +); +``` + +## 実行コマンド + +### 基本テスト実行 +```bash +# Vitest単体 +pnpm run test:vitest + +# Vitest単体(coverageあり) +pnpm run test:vitest:coverage + +# 特定ファイルのみ実行(coverageあり) +pnpm run test:vitest src/path/to/test.spec.tsx +``` + +### package.jsonスクリプト参照 +```json +{ + "scripts": { + "test": "run-p test:*", + "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest", + "test:vitest": "vitest run --coverage" + } +} +``` + +## Jest→Vitest移行要点 +- `jest.config.js` → `vitest.config.ts` +- `@types/jest` → `vitest/globals` +- ESModulesネイティブサポート → 高速起動・実行 + +この設定により型安全性と保守性を両立した高品質テストが可能。 \ No newline at end of file diff --git a/.serena/serena_config.yml b/.serena/serena_config.yml new file mode 100644 index 00000000000..f95ea3fd557 --- /dev/null +++ b/.serena/serena_config.yml @@ -0,0 +1,10 @@ +web_dashboard: false +# whether to open the Serena web dashboard (which will be accessible through your web browser) that +# shows Serena's current session logs - as an alternative to the GUI log window which +# is supported on all platforms. + +web_dashboard_open_on_launch: false +# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard +# is enabled). If set to False, you can still open the dashboard manually by navigating to +# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard). +# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc. diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c65cfc54b..8307593fb58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,66 @@ # Changelog -## [Unreleased](https://github.com/growilabs/compare/v7.3.4...HEAD) +## [Unreleased](https://github.com/growilabs/compare/v7.3.9...HEAD) *Please do not manually update this file. We've automated the process.* +## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09 + +### 🐛 Bug Fixes + +* fix: Change the name of maintenance mode. (#10559) @hikaru-n-cpu + +### 🧰 Maintenance + +* support: Add new intern names to staff credits (#10556) @riona-k + +## [v7.3.8](https://github.com/growilabs/compare/v7.3.7...v7.3.8) - 2025-12-04 + +### 💎 Features + +* feat: Enable page bulk export for GROWI.cloud (#10292) @arafubeatbox +* feat: Users statistics table for admin (#10539) @riona-k + +### 🧰 Maintenance + +* ci(deps): bump validator from 13.15.20 to 13.15.22 (#10560) @[dependabot[bot]](https://github.com/apps/dependabot) + +## [v7.3.7](https://github.com/growilabs/compare/v7.3.6...v7.3.7) - 2025-11-25 + +### 💎 Features + +* feat(pdf-converter): Enable puppeteer-cluster config of pdf-converter from env var (#10516) @arafubeatbox + +### 🐛 Bug Fixes + +* fix: Admin form degradation (#10540) @yuki-takei + +## [v7.3.6](https://github.com/growilabs/compare/v7.3.5...v7.3.6) - 2025-11-18 + +### 🐛 Bug Fixes + +* fix: Printing styles (#10505) @yuki-takei + +### 🧰 Maintenance + +* ci(deps): bump js-yaml from 4.1.0 to 4.1.1 (#10511) @[dependabot[bot]](https://github.com/apps/dependabot) +* support: Configure biome for app routes excluding apiv3 (#10496) @arafubeatbox + +## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10 + +### 💎 Features + +* feat: Activity Log on the user page for viewing recent activity (#10487) @arvid-e + +### 🐛 Bug Fixes + +* fix: PDF-converter major/minor tags not updated on release (#10476) @arafubeatbox + +### 🧰 Maintenance + +* support: Configure biome for app/src/server/models dir (#10419) @arafubeatbox +* support: Playwright tests biome migration (#10248) @arafubeatbox + ## [v7.3.4](https://github.com/growilabs/compare/v7.3.3...v7.3.4) - 2025-11-04 ### 🚀 Improvement diff --git a/CLAUDE.md b/CLAUDE.md index 432a772be7a..ce91e507cd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Language -If it is detected at the start or during a session that the user's primary language is not English, always respond in that language from then on. However, technical terms may remain in English as needed. +If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, we may retain technical terms in English if necessary. + +When generating source code, all comments and explanations within the code will be written in English. ## Project Overview diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js index 61773f717b8..be2edbd68be 100644 --- a/apps/app/.eslintrc.js +++ b/apps/app/.eslintrc.js @@ -32,23 +32,13 @@ module.exports = { 'src/linter-checker/**', 'src/migrations/**', 'src/models/**', - 'src/features/callout/**', - 'src/features/comment/**', - 'src/features/templates/**', - 'src/features/mermaid/**', - 'src/features/search/**', - 'src/features/plantuml/**', - 'src/features/external-user-group/**', - 'src/features/page-bulk-export/**', - 'src/features/growi-plugin/**', - 'src/features/opentelemetry/**', - 'src/features/openai/**', - 'src/features/rate-limiter/**', + 'src/features/**', 'src/stores-universal/**', 'src/interfaces/**', 'src/utils/**', 'src/components/**', 'src/services/**', + 'src/states/**', 'src/stores/**', 'src/pages/**', 'src/server/crowi/**', @@ -58,6 +48,43 @@ module.exports = { 'src/server/util/**', 'src/server/app.ts', 'src/server/repl.ts', + 'src/server/middlewares/**', + 'src/server/routes/*.js', + 'src/server/routes/*.ts', + 'src/server/routes/attachment/**', + 'src/server/routes/apiv3/interfaces/**', + 'src/server/routes/apiv3/pages/**', + 'src/server/routes/apiv3/user/**', + 'src/server/routes/apiv3/personal-setting/**', + 'src/server/routes/apiv3/security-settings/**', + 'src/server/routes/apiv3/app-settings/**', + 'src/server/routes/apiv3/page/**', + 'src/server/routes/apiv3/*.js', + 'src/server/routes/apiv3/*.ts', + 'src/server/service/*.ts', + 'src/server/service/*.js', + 'src/server/service/access-token/**', + 'src/server/service/config-manager/**', + 'src/server/service/page/**', + 'src/server/service/page-listing/**', + 'src/server/service/revision/**', + 'src/server/service/s2s-messaging/**', + 'src/server/service/search-delegator/**', + 'src/server/service/search-reconnect-context/**', + 'src/server/service/slack-command-handler/**', + 'src/server/service/slack-event-handler/**', + 'src/server/service/socket-io/**', + 'src/server/service/system-events/**', + 'src/server/service/user-notification/**', + 'src/server/service/yjs/**', + 'src/server/service/file-uploader/**', + 'src/server/service/global-notification/**', + 'src/server/service/growi-bridge/**', + 'src/server/service/growi-info/**', + 'src/server/service/import/**', + 'src/server/service/in-app-notification/**', + 'src/server/service/interfaces/**', + 'src/server/service/normalize-data/**', ], settings: { // resolve path aliases by eslint-import-resolver-typescript @@ -66,6 +93,7 @@ module.exports = { }, }, rules: { + 'space-before-function-paren': 'off', '@typescript-eslint/no-var-requires': 'off', // set 'warn' temporarily -- 2021.08.02 Yuki Takei diff --git a/apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c b/apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c new file mode 100644 index 00000000000..bbbc3452344 Binary files /dev/null and b/apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c differ diff --git a/apps/app/bin/github-actions/update-readme.sh b/apps/app/bin/github-actions/update-readme.sh index 29ef8885185..3f2020bd4f8 100644 --- a/apps/app/bin/github-actions/update-readme.sh +++ b/apps/app/bin/github-actions/update-readme.sh @@ -2,4 +2,4 @@ cd docker -sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/apps\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md +sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.4\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/apps\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md diff --git a/apps/app/docker/README.md b/apps/app/docker/README.md index 8949a6b67f3..0e913e0479a 100644 --- a/apps/app/docker/README.md +++ b/apps/app/docker/README.md @@ -10,9 +10,9 @@ GROWI Official docker image Supported tags and respective Dockerfile links ------------------------------------------------ -* [`7.1.0`, `7.1`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.1.0/apps/app/docker/Dockerfile) -* [`7.0.23`, `7.0` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.0.23/apps/app/docker/Dockerfile) -* [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/growilabs/growi/blob/v6.3.2/apps/app/docker/Dockerfile) +* [`7.4.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.0/apps/app/docker/Dockerfile) +* [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile) +* [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile) What is GROWI? diff --git a/apps/app/docs/plan/README.md b/apps/app/docs/plan/README.md new file mode 100644 index 00000000000..944e685c914 --- /dev/null +++ b/apps/app/docs/plan/README.md @@ -0,0 +1,40 @@ +# React State Management Documentation + +# React State Management Documentation + +## Current Documentation + +### [`jotai-migration.md`](jotai-migration.md) +Jotai移行のガイドドキュメント(実装方針・パターン) + +- 移行方針と背景 +- 実装パターンとガイドライン +- 判断基準とベストプラクティス +- 移行の成果と技術スタック + +### [`jotai-migration-progress.md`](jotai-migration-progress.md) +実装進捗と次のステップ(随時更新) + +- 完了済み実装の一覧 +- 次の実装ステップと優先順位 +- 進捗サマリーと更新履歴 + +--- + +## ドキュメント構造について + +### 設計方針 +**役割分離**: 安定的なガイドと頻繁に更新される進捗を分離 + +- **`jotai-migration.md`**: 実装方針とパターン(安定的) +- **`jotai-migration-progress.md`**: 進捗と次のステップ(頻繁更新) + +### メリット +- **メンテナンス性向上**: 更新頻度に応じた適切な構造 +- **情報の明確化**: 各ドキュメントの責務が明確 +- **開発効率向上**: 必要な情報に素早くアクセス可能 + +### 推奨される利用方法 +1. **新規参入者**: `jotai-migration.md` で実装方針とパターンを理解 +2. **開発者**: `jotai-migration-progress.md` で次のタスクを確認 +3. **レビュー**: `jotai-migration.md` の実装パターンで一貫性を保証 diff --git a/apps/app/next.config.js b/apps/app/next.config.js index 78c1c872aba..9b63078c004 100644 --- a/apps/app/next.config.js +++ b/apps/app/next.config.js @@ -58,6 +58,7 @@ const getTranspilePackages = () => { 'github-slugger', 'html-url-attributes', 'estree-util-is-identifier-name', + 'superjson', ...listPrefixedPackages([ 'remark-', 'rehype-', @@ -159,8 +160,10 @@ module.exports = async (phase) => { }; // production server + // Skip withSuperjson() in production server phase because the pages directory + // doesn't exist in the production build and withSuperjson() tries to find it if (phase === PHASE_PRODUCTION_SERVER) { - return withSuperjson()(nextConfig); + return nextConfig; } const withBundleAnalyzer = require('@next/bundle-analyzer')({ diff --git a/apps/app/package.json b/apps/app/package.json index aca90c5bc39..f562913ebe5 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,6 +1,6 @@ { "name": "@growi/app", - "version": "7.3.5-RC.0", + "version": "7.4.0-RC.0", "license": "MIT", "private": "true", "scripts": { @@ -28,7 +28,7 @@ "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci", "lint:typecheck": "vue-tsc --noEmit", "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"", - "lint:biome": "biome check", + "lint:biome": "biome check --diagnostic-level=error", "lint:styles": "stylelint \"src/**/*.scss\"", "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json", "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json", @@ -127,7 +127,6 @@ "diff_match_patch": "^0.1.1", "dotenv-flow": "^3.2.0", "ejs": "^3.1.10", - "esa-node": "^0.2.2", "escape-string-regexp": "^4.0.0", "expose-gc": "^1.0.0", "express": "^4.20.0", @@ -147,8 +146,10 @@ "i18next-resources-to-backend": "^1.2.1", "is-absolute-url": "^4.0.1", "is-iso-date": "^0.0.1", + "jotai": "^2.12.3", + "js-cookie": "^3.0.5", "js-tiktoken": "^1.0.15", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "jsonrepair": "^3.12.0", "katex": "^0.16.21", "ldapjs": "^3.0.2", @@ -175,7 +176,7 @@ "next": "^14.2.32", "next-dynamic-loading-props": "^0.1.1", "next-i18next": "^15.3.1", - "next-superjson": "^0.0.4", + "next-superjson": "^1.0.7", "next-themes": "^0.2.1", "nocache": "^4.0.0", "node-cron": "^3.0.2", @@ -230,7 +231,7 @@ "sanitize-filename": "^1.6.3", "socket.io": "^4.7.5", "string-width": "=4.2.2", - "superjson": "^1.9.1", + "superjson": "^2.2.2", "swagger-jsdoc": "^6.2.8", "swr": "^2.3.2", "throttle-debounce": "^5.0.0", @@ -246,7 +247,7 @@ "url-join": "^4.0.0", "usehooks-ts": "^2.6.0", "uuid": "^11.0.3", - "validator": "^13.15.20", + "validator": "^13.15.22", "ws": "^8.17.1", "xss": "^1.0.15", "y-mongodb-provider": "^0.2.0", @@ -269,10 +270,13 @@ "@growi/editor": "workspace:^", "@growi/ui": "workspace:^", "@handsontable/react": "=2.1.0", + "@headless-tree/core": "^1.5.1", + "@headless-tree/react": "^1.5.1", "@next/bundle-analyzer": "^14.1.3", "@popperjs/core": "^2.11.8", "@swc-node/jest": "^1.8.1", "@swc/jest": "^0.2.36", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/jest-dom": "^6.5.0", "@testing-library/user-event": "^14.5.2", "@types/archiver": "^6.0.2", @@ -280,6 +284,7 @@ "@types/express": "^4.17.21", "@types/hast": "^3.0.4", "@types/jest": "^29.5.2", + "@types/js-cookie": "^3.0.6", "@types/ldapjs": "^2.2.5", "@types/mdast": "^4.0.4", "@types/node-cron": "^3.0.11", @@ -315,6 +320,7 @@ "jest": "^29.5.0", "jest-date-mock": "^1.0.8", "jest-localstorage-mock": "^2.4.14", + "jotai-devtools": "^0.11.0", "load-css-file": "^1.0.0", "material-icons": "^1.11.3", "mdast-util-directive": "^3.0.0", diff --git a/apps/app/public/static/locales/en_US/admin.json b/apps/app/public/static/locales/en_US/admin.json index 388c8d09f0a..d6acb02ba04 100644 --- a/apps/app/public/static/locales/en_US/admin.json +++ b/apps/app/public/static/locales/en_US/admin.json @@ -339,7 +339,7 @@ "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.", "start_maintenance_mode": "Start maintenance mode", "end_maintenance_mode": "End maintenance mode", - "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"." + "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"App Settings\" > \"Maintenance Mode\"." }, "app_setting": { "site_name": "Site name", @@ -531,7 +531,7 @@ "page_path": "Page Path", "beta_warning": "This function is Beta.", "import_from": "Import from {{from}}", - "import_growi_archive": "Import GROWI archive", + "import_growi_archive": "Import Archive Data", "error": { "only_upsert_available": "Only 'Upsert' option is available for pages collection." }, @@ -577,23 +577,11 @@ } } }, - "esa_settings": { - "team_name": "Team name", - "access_token": "Access token", - "test_connection": "Test connection to esa" - }, - "qiita_settings": { - "team_name": "Team name", - "access_token": "Access token", - "test_connection": "Test connection to qiita:team" - }, "import": "Import", "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment", "prepare_new_account_for_migration": "Prepare new account for migration", "archive_data_import_detail": "More Details? Ckick here.", - "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html", - "page_skip": "Pages with a name that already exists on GROWI are not imported", - "Directory_hierarchy_tag": "Directory hierarchy tag" + "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html" }, "export_management": { "export_archive_data": "Export Archive Data", @@ -796,7 +784,11 @@ "unset": "No", "related_username": "Related user's ", "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.", - "current_users": "Current users:" + "user_statistics": { + "total": "Total Users", + "active": "Active", + "inactive": "Inactive" + } }, "user_group_management": { "user_group_management": "User Group Management", @@ -1011,12 +1003,6 @@ "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data", "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data", "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard Archived Data", - "ADMIN_ESA_DATA_IMPORTED": "Import from esa.io", - "ADMIN_ESA_DATA_UPDATED": "Update esa.io import settings", - "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Test connection to esa", - "ADMIN_QIITA_DATA_IMPORTED": "Import from Qiita:Team", - "ADMIN_QIITA_DATA_UPDATED": "Update Qiita:Team import settings", - "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Test connection to Qiita:Team", "ADMIN_ARCHIVE_DATA_CREATE": "Create Archived Data", "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data", "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data", diff --git a/apps/app/public/static/locales/en_US/translation.json b/apps/app/public/static/locales/en_US/translation.json index 413636484c9..64dcaa9db8c 100644 --- a/apps/app/public/static/locales/en_US/translation.json +++ b/apps/app/public/static/locales/en_US/translation.json @@ -999,7 +999,18 @@ }, "user_home_page": { "bookmarks": "Bookmarks", - "recently_created": "Recently Created" + "recently_created": "Recently Created", + "recent_activity": "Recent Activity", + "unknown_action": "made an unspecified change", + "page_create": "created a page", + "page_update": "updated a page", + "page_delete": "deleted a page", + "page_delete_completely": "deleted a page", + "page_rename": "renamed a page", + "page_revert": "reverted a page", + "page_like": "liked a page", + "page_duplicate": "duplicated a page", + "comment_create": "posted a comment" }, "bookmark_folder": { "bookmark_folder": "bookmark folder", diff --git a/apps/app/public/static/locales/fr_FR/admin.json b/apps/app/public/static/locales/fr_FR/admin.json index 132fc234a8d..032b8df7bee 100644 --- a/apps/app/public/static/locales/fr_FR/admin.json +++ b/apps/app/public/static/locales/fr_FR/admin.json @@ -339,7 +339,7 @@ "supplymentary_message_to_start": "Seul l'API d'administration sera actif.", "start_maintenance_mode": "Activer le mode maitenance", "end_maintenance_mode": "Désactiver le mode maitenance", - "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\"." + "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\".Pour quitter ce mode, veuillez vous rendre dans « Paramètres de l'application » > « Mode maintenance »." }, "app_setting": { "site_name": "Nom", @@ -531,7 +531,7 @@ "page_path": "Chemin de page", "beta_warning": "Cette fonctionnalité est en beta.", "import_from": "Importer depuis {{from}}", - "import_growi_archive": "Importer une archive GROWI", + "import_growi_archive": "Importer les données d'archive", "error": { "only_upsert_available": "Seul l'option 'Upsert' est disponible pour les collections de pages" }, @@ -577,23 +577,11 @@ } } }, - "esa_settings": { - "team_name": "Nom de l'équipe", - "access_token": "Jeton d'accès", - "test_connection": "Essai de la connection esa" - }, - "qiita_settings": { - "team_name": "Nom de l'équipe", - "access_token": "Jeton d'accès", - "test_connection": "Essai de la connection qiita:team" - }, "import": "Importer", "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement", "prepare_new_account_for_migration": "Préparer le compte pour la migration", "archive_data_import_detail": "En savoir plus", - "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html", - "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.", - "Directory_hierarchy_tag": "Tag de hiérarchie" + "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html" }, "export_management": { "export_archive_data": "Archive de données d'export", @@ -796,7 +784,11 @@ "unset": "Non", "related_username": "Utilisateur ", "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.", - "current_users": "Utilisateurs:" + "user_statistics": { + "total": "Utilisateurs totaux", + "active": "Actifs", + "inactive": "Inactifs" + } }, "user_group_management": { "user_group_management": "Gestion des groupes", @@ -1010,12 +1002,6 @@ "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive", "ADMIN_GROWI_DATA_IMPORTED": "Importer les données d'archive", "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Supprimer les données d'archive", - "ADMIN_ESA_DATA_IMPORTED": "Importer depuis esa.io", - "ADMIN_ESA_DATA_UPDATED": "Mettre à jour les paramètres d'import esa.io", - "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Tester la connexion esa", - "ADMIN_QIITA_DATA_IMPORTED": "Importer depuis Qiita:Team", - "ADMIN_QIITA_DATA_UPDATED": "Mettre à jour les paramètres d'import Qiita:Team", - "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Tester la connexion Qiita:Team", "ADMIN_ARCHIVE_DATA_CREATE": "Créer données d'archive", "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Télécharger les données d'archive", "ADMIN_ARCHIVE_DATA_DELETE": "Supprimer les données d'archive", diff --git a/apps/app/public/static/locales/fr_FR/translation.json b/apps/app/public/static/locales/fr_FR/translation.json index 0ac01bb785e..3a6f2792703 100644 --- a/apps/app/public/static/locales/fr_FR/translation.json +++ b/apps/app/public/static/locales/fr_FR/translation.json @@ -993,7 +993,18 @@ }, "user_home_page": { "bookmarks": "Favoris", - "recently_created": "Page récentes" + "recently_created": "Page récentes", + "recent_activity": "Activité récente", + "unknown_action": "a effectué une modification non spécifiée", + "page_create": "a créé une page", + "page_update": "a mis à jour une page", + "page_delete": "a supprimé une page", + "page_delete_completely": "a supprimé complètement une page", + "page_rename": "a renommé une page", + "page_revert": "a restauré une page", + "page_duplicate": "a dupliqué une page", + "page_like": "a aimé une page", + "comment_create": "a publié un commentaire" }, "bookmark_folder": { "bookmark_folder": "dossier de favoris", diff --git a/apps/app/public/static/locales/ja_JP/admin.json b/apps/app/public/static/locales/ja_JP/admin.json index 6e8254fefff..fbea2868428 100644 --- a/apps/app/public/static/locales/ja_JP/admin.json +++ b/apps/app/public/static/locales/ja_JP/admin.json @@ -348,7 +348,7 @@ "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。", "start_maintenance_mode": "メンテナンスモードを開始する", "end_maintenance_mode": "メンテナンスモードを終了する", - "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。" + "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「アプリ設定」>「メンテナンスモード」から操作してください。" }, "app_setting": { "site_name": "サイト名", @@ -540,7 +540,7 @@ "page_path": "ページパス", "beta_warning": "この機能はベータ版です", "import_from": "{{from}} からインポート", - "import_growi_archive": "GROWI アーカイブをインポート", + "import_growi_archive": "データインポート", "error": { "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています" }, @@ -586,23 +586,11 @@ } } }, - "esa_settings": { - "team_name": "チーム名", - "access_token": "アクセストークン", - "test_connection": "接続テスト" - }, - "qiita_settings": { - "team_name": "チーム名", - "access_token": "アクセストークン", - "test_connection": "接続テスト" - }, "import": "インポート", "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。", "prepare_new_account_for_migration": "移行用のアカウントを新環境で用意してください。", "archive_data_import_detail": "参考: GROWI Docs - データのインポート", - "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88", - "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます", - "Directory_hierarchy_tag": "ディレクトリ階層タグ" + "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88" }, "export_management": { "export_archive_data": "データアーカイブ", @@ -805,7 +793,11 @@ "unset": "未設定", "related_username": "関連付けられているユーザーの ", "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。", - "current_users": "現在のユーザー数:" + "user_statistics": { + "total": "総ユーザー数", + "active": "アクティブ", + "inactive": "非アクティブ" + } }, "user_group_management": { "user_group_management": "グループ管理", @@ -1020,12 +1012,6 @@ "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード", "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート", "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄", - "ADMIN_ESA_DATA_IMPORTED": "esa.io からインポート", - "ADMIN_ESA_DATA_UPDATED": "esa.io のインポート設定の更新", - "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa.io の接続テスト", - "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team からのインポート", - "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team のインポート設定の更新", - "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team の接続テスト", "ADMIN_ARCHIVE_DATA_CREATE": "アーカイブデータの作成", "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード", "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除", diff --git a/apps/app/public/static/locales/ja_JP/translation.json b/apps/app/public/static/locales/ja_JP/translation.json index 2f90269d566..3afef6febc5 100644 --- a/apps/app/public/static/locales/ja_JP/translation.json +++ b/apps/app/public/static/locales/ja_JP/translation.json @@ -1032,7 +1032,18 @@ }, "user_home_page": { "bookmarks": "ブックマーク", - "recently_created": "最近作成したページ" + "recently_created": "最近作成したページ", + "recent_activity": "最近のアクティビティ", + "unknown_action": "未指定の変更を加えました", + "page_create": "ページを作成しました", + "page_update": "ページを更新しました", + "page_delete": "ページを削除しました", + "page_delete_completely": "ページを完全に削除しました", + "page_rename": "ページの名前を変更しました", + "page_revert": "ページを元に戻しました", + "page_duplicate": "ページを複製しました", + "page_like": "ページをいいねしました", + "comment_create": "コメントを投稿しました" }, "bookmark_folder": { "bookmark_folder": "ブックマークフォルダ", diff --git a/apps/app/public/static/locales/ko_KR/admin.json b/apps/app/public/static/locales/ko_KR/admin.json index 5f6d90daf70..27b4151aefd 100644 --- a/apps/app/public/static/locales/ko_KR/admin.json +++ b/apps/app/public/static/locales/ko_KR/admin.json @@ -339,7 +339,7 @@ "supplymentary_message_to_start": "API의 경우 관리자 API만 작동합니다.", "start_maintenance_mode": "유지 보수 모드 시작", "end_maintenance_mode": "유지 보수 모드 종료", - "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오." + "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오.종료하려면 ‘앱 설정’ > '유지보수 모드'에서 조작하십시오." }, "app_setting": { "site_name": "사이트 이름", @@ -531,7 +531,7 @@ "page_path": "페이지 경로", "beta_warning": "이 기능은 베타입니다.", "import_from": "{{from}}에서 가져오기", - "import_growi_archive": "GROWI 아카이브 가져오기", + "import_growi_archive": "아카이브 데이터 가져오기", "error": { "only_upsert_available": "페이지 컬렉션에는 'Upsert' 옵션만 사용할 수 있습니다." }, @@ -577,23 +577,11 @@ } } }, - "esa_settings": { - "team_name": "팀 이름", - "access_token": "액세스 토큰", - "test_connection": "esa 연결 테스트" - }, - "qiita_settings": { - "team_name": "팀 이름", - "access_token": "액세스 토큰", - "test_connection": "qiita:team 연결 테스트" - }, "import": "가져오기", "skip_username_and_email_when_overlapped": "새 환경에서 동일한 사용자 이름과 이메일을 사용하는 경우 사용자 이름과 이메일 건너뛰기", "prepare_new_account_for_migration": "마이그레이션을 위한 새 계정 준비", "archive_data_import_detail": "자세한 내용은 여기를 클릭하십시오.", - "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html", - "page_skip": "GROWI에 이미 존재하는 이름의 페이지는 가져오지 않습니다.", - "Directory_hierarchy_tag": "디렉토리 계층 태그" + "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html" }, "export_management": { "export_archive_data": "아카이브 데이터 내보내기", @@ -796,7 +784,11 @@ "unset": "아니요", "related_username": "관련 사용자 ", "cannot_invite_maximum_users": "최대 사용자 수 이상을 초대할 수 없습니다.", - "current_users": "현재 사용자:" + "user_statistics": { + "total": "총 사용자", + "active": "활성", + "inactive": "비활성" + } }, "user_group_management": { "user_group_management": "사용자 그룹 관리", @@ -1011,12 +1003,6 @@ "ADMIN_ARCHIVE_DATA_UPLOAD": "아카이브 데이터 업로드", "ADMIN_GROWI_DATA_IMPORTED": "아카이브 데이터 가져오기", "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "업로드된 GROWI 데이터 버리기", - "ADMIN_ESA_DATA_IMPORTED": "esa.io에서 가져오기", - "ADMIN_ESA_DATA_UPDATED": "esa.io 가져오기 설정 업데이트", - "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa 연결 테스트", - "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team에서 가져오기", - "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team 가져오기 설정 업데이트", - "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team 연결 테스트", "ADMIN_ARCHIVE_DATA_CREATE": "아카이브 데이터 생성", "ADMIN_ARCHIVE_DATA_DOWNLOAD": "아카이브 데이터 다운로드", "ADMIN_ARCHIVE_DATA_DELETE": "아카이브 데이터 삭제", diff --git a/apps/app/public/static/locales/ko_KR/translation.json b/apps/app/public/static/locales/ko_KR/translation.json index 498e8475a5a..6951b1f0b41 100644 --- a/apps/app/public/static/locales/ko_KR/translation.json +++ b/apps/app/public/static/locales/ko_KR/translation.json @@ -959,7 +959,18 @@ }, "user_home_page": { "bookmarks": "북마크", - "recently_created": "최근 생성됨" + "recently_created": "최근 생성됨", + "recent_activity": "최근 활동", + "unknown_action": "지정되지 않은 변경 사항을 적용했습니다", + "page_create": "페이지를 생성했습니다", + "page_update": "페이지를 업데이트했습니다", + "page_delete": "페이지를 삭제했습니다", + "page_delete_completely": "페이지를 완전히 삭제했습니다", + "page_rename": "페이지 이름을 변경했습니다", + "page_revert": "페이지를 되돌렸습니다", + "page_duplicate": "페이지를 복제했습니다", + "page_like": "페이지에 좋아요를 눌렀습니다", + "comment_create": "댓글을 게시했습니다" }, "bookmark_folder": { "bookmark_folder": "북마크 폴더", diff --git a/apps/app/public/static/locales/zh_CN/admin.json b/apps/app/public/static/locales/zh_CN/admin.json index 005575f6027..2437d1e039c 100644 --- a/apps/app/public/static/locales/zh_CN/admin.json +++ b/apps/app/public/static/locales/zh_CN/admin.json @@ -348,7 +348,7 @@ "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。", "start_maintenance_mode": "启动维护模式", "end_maintenance_mode": "结束维护模式", - "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。" + "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"系统设置\">\"维护模式\"。" }, "app_setting": { "site_name": "网站名称 ", @@ -540,7 +540,7 @@ "page_path": "相对路径", "beta_warning": "这个函数是Beta。", "import_from": "Import from {{from}}", - "import_growi_archive": "Import GROWI archive", + "import_archive_data": "导入存档数据", "error": { "only_upsert_available": "Only 'Upsert' option is available for pages collection." }, @@ -586,23 +586,11 @@ } } }, - "esa_settings": { - "team_name": "Team name", - "access_token": "Access token", - "test_connection": "Test connection to esa" - }, - "qiita_settings": { - "team_name": "Team name", - "access_token": "Access token", - "test_connection": "Test connection to qiita:team" - }, "import": "Import", "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment", "prepare_new_account_for_migration": "Prepare new account for migration", "archive_data_import_detail": "More details? Click here.", - "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html", - "page_skip": "Pages with a name that already exists on GROWI are not imported", - "Directory_hierarchy_tag": "Directory hierarchy tag" + "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html" }, "export_management": { "export_archive_data": "导出主题数据", @@ -805,7 +793,11 @@ "unset": "否", "related_username": "相关用户的", "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。", - "current_users": "当前用户:" + "user_statistics": { + "total": "用户总数", + "active": "活跃", + "inactive": "非活跃" + } }, "user_group_management": { "user_group_management": "用户组管理", @@ -1020,12 +1012,6 @@ "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据", "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据", "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据", - "ADMIN_ESA_DATA_IMPORTED": "从 esa.io 导入", - "ADMIN_ESA_DATA_UPDATED": "更新 esa.io 导入设置", - "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "测试与 esa 的连接", - "ADMIN_QIITA_DATA_IMPORTED": "从 Qiita:Team 导入", - "ADMIN_QIITA_DATA_UPDATED": "更新 Qiita:团队导入设置", - "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "测试与 Qiita:Team 的连接", "ADMIN_ARCHIVE_DATA_CREATE": "创建归档数据", "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据", "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据", diff --git a/apps/app/public/static/locales/zh_CN/translation.json b/apps/app/public/static/locales/zh_CN/translation.json index 050b740b50b..fa2d3ef01ca 100644 --- a/apps/app/public/static/locales/zh_CN/translation.json +++ b/apps/app/public/static/locales/zh_CN/translation.json @@ -1004,7 +1004,18 @@ }, "user_home_page": { "bookmarks": "书签", - "recently_created": "最近创建页面" + "recently_created": "最近创建页面", + "recent_activity": "最近动态", + "unknown_action": "进行了未指明的更改", + "page_create": "创建了页面", + "page_update": "更新了页面", + "page_delete": "删除了页面", + "page_delete_completely": "彻底删除了页面", + "page_rename": "重命名了页面", + "page_revert": "还原了页面", + "page_duplicate": "复制了页面", + "page_like": "赞了页面", + "comment_create": "发布了评论" }, "bookmark_folder": { "bookmark_folder": "书签文件夹", diff --git a/apps/app/resource/Contributor.js b/apps/app/resource/Contributor.js index eb224e11971..1cfee4553d9 100644 --- a/apps/app/resource/Contributor.js +++ b/apps/app/resource/Contributor.js @@ -78,6 +78,13 @@ const contributors = [ { name: 'shironegi39' }, { name: 'ryo-h15' }, { name: 'jam411' }, + { name: 'Naoki427' }, + { name: 'yusa-bot' }, + { name: 'arvid-e' }, + { name: 'riona-k' }, + { name: 'hiroki-hgs' }, + { name: 'taikou-m' }, + { name: 'hikaru-n-cpu' }, ], }, ], diff --git a/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx b/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx index 5e735a5a930..fc43abd8c32 100644 --- a/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx +++ b/apps/app/src/client/components/Admin/AdminHome/AdminHome.jsx @@ -17,6 +17,7 @@ import { withUnstatedContainers } from '../../UnstatedUtils'; import { EnvVarsTable } from './EnvVarsTable'; import SystemInfomationTable from './SystemInfomationTable'; + const logger = loggerFactory('growi:admin'); const AdminHome = (props) => { @@ -59,7 +60,7 @@ const AdminHome = (props) => { ) } { - // Alert message will be displayed in case that V5 migration has not been compleated + // Alert message will be displayed in case that V5 migration has not been compleated (migrationStatus != null && !migrationStatus.isV5Compatible) && (
@@ -90,7 +91,7 @@ const AdminHome = (props) => {

{t('admin:admin_top.env_var_priority')}

{/* eslint-disable-next-line react/no-danger */}

- {adminHomeContainer.state.envVars && } +

diff --git a/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx b/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx index 9a25953ae46..ccb5d46cbcb 100644 --- a/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx +++ b/apps/app/src/client/components/Admin/AdminHome/EnvVarsTable.tsx @@ -1,13 +1,20 @@ import React, { type JSX } from 'react'; +import { LoadingSpinner } from '@growi/ui/dist/components'; + type EnvVarsTableProps = { - envVars: Record, + envVars?: Record, } export const EnvVarsTable: React.FC = (props: EnvVarsTableProps) => { + const { envVars } = props; + if (envVars == null) { + return ; + } + const envVarRows: JSX.Element[] = []; - for (const [key, value] of Object.entries(props.envVars)) { + for (const [key, value] of Object.entries(envVars ?? {})) { if (value != null) { envVarRows.push( diff --git a/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx b/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx index e1ef6a63cfd..c1ce1241987 100644 --- a/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx +++ b/apps/app/src/client/components/Admin/AdminHome/SystemInfomationTable.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { LoadingSpinner } from '@growi/ui/dist/components'; + import AdminHomeContainer from '~/client/services/AdminHomeContainer'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -17,7 +19,7 @@ const SystemInformationTable = (props: Props) => { } = adminHomeContainer.state; if (growiVersion == null || nodeVersion == null || npmVersion == null || pnpmVersion == null) { - return <>; + return ; } return ( diff --git a/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx b/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx index 16d086a2b77..0d49a23bbc0 100644 --- a/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx +++ b/apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next'; import AdminAppContainer from '~/client/services/AdminAppContainer'; import { toastError } from '~/client/util/toastr'; -import { useIsMaintenanceMode } from '~/stores/maintenanceMode'; +import { useIsMaintenanceMode } from '~/states/global'; import { toArrayIfNot } from '~/utils/array-utils'; import loggerFactory from '~/utils/logger'; @@ -29,7 +29,7 @@ const AppSettingsPageContents = (props: Props) => { const { t } = useTranslation('admin'); const { adminAppContainer } = props; - const { data: isMaintenanceMode } = useIsMaintenanceMode(); + const isMaintenanceMode = useIsMaintenanceMode(); const { isV5Compatible } = adminAppContainer.state; @@ -108,15 +108,12 @@ const AppSettingsPageContents = (props: Props) => { - {/* TODO: Enable configuring bulk export for GROWI.cloud when it can be relased for cloud (https://redmine.weseek.co.jp/issues/163220) */} - {!adminAppContainer.state.isBulkExportDisabledForCloud && ( -
-
-

{t('admin:app_setting.page_bulk_export_settings')}

- -
+
+
+

{t('admin:app_setting.page_bulk_export_settings')}

+
- )} +
diff --git a/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx b/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx index 846537bed97..8f73c4e5785 100644 --- a/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx +++ b/apps/app/src/client/components/Admin/App/MaintenanceMode.tsx @@ -3,21 +3,18 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'next-i18next'; +import { useMaintenanceModeActions } from '~/client/services/maintenance-mode'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useIsMaintenanceMode } from '~/stores/maintenanceMode'; -import loggerFactory from '~/utils/logger'; +import { useIsMaintenanceMode } from '~/states/global'; import { ConfirmModal } from './ConfirmModal'; -const logger = loggerFactory('growi:maintenanceMode'); - export const MaintenanceMode: FC = () => { const { t } = useTranslation(); - const { - data: isMaintenanceMode, start: startMaintenanceMode, end: endMaintenanceMode, - } = useIsMaintenanceMode(); + const isMaintenanceMode = useIsMaintenanceMode(); + const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions(); const [isModalOpen, setModalOpen] = useState(false); diff --git a/apps/app/src/client/components/Admin/App/V5PageMigration.tsx b/apps/app/src/client/components/Admin/App/V5PageMigration.tsx index 32587d48355..02fa341197d 100644 --- a/apps/app/src/client/components/Admin/App/V5PageMigration.tsx +++ b/apps/app/src/client/components/Admin/App/V5PageMigration.tsx @@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'next-i18next'; import { toastError, toastSuccess } from '~/client/util/toastr'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import type { PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData, } from '~/interfaces/websocket'; import { SocketEventName, } from '~/interfaces/websocket'; -import { useGlobalAdminSocket } from '~/stores/websocket'; import AdminAppContainer from '../../../services/AdminAppContainer'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -33,7 +33,7 @@ const V5PageMigration: FC = (props: Props) => { const [current, setCurrent] = useState(0); const [isSucceeded, setSucceeded] = useState(undefined); - const { data: adminSocket } = useGlobalAdminSocket(); + const adminSocket = useAdminSocket(); const { t } = useTranslation(); const { adminAppContainer } = props; diff --git a/apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx b/apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx index 438f33cb497..2704bf28644 100644 --- a/apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx +++ b/apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx @@ -1,22 +1,21 @@ import type { FC } from 'react'; import React, { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import { Collapse } from 'reactstrap'; import { AllSupportedActions } from '~/interfaces/activity'; -import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores-universal/context'; +import { activityExpirationSecondsAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations'; export const AuditLogSettings: FC = () => { const { t } = useTranslation(); const [isExpandActionList, setIsExpandActionList] = useState(false); - const { data: activityExpirationSecondsData } = useActivityExpirationSeconds(); - const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000; + const activityExpirationSeconds = useAtomValue(activityExpirationSecondsAtom) || 2592000; - const { data: availableActionsData } = useAuditLogAvailableActions(); - const availableActions = availableActionsData != null ? availableActionsData : []; + const availableActions = useAtomValue(auditLogAvailableActionsAtom); return ( <> diff --git a/apps/app/src/client/components/Admin/AuditLogManagement.tsx b/apps/app/src/client/components/Admin/AuditLogManagement.tsx index be3c31caa9c..8a8704908db 100644 --- a/apps/app/src/client/components/Admin/AuditLogManagement.tsx +++ b/apps/app/src/client/components/Admin/AuditLogManagement.tsx @@ -3,12 +3,13 @@ import React, { useState, useCallback, useRef } from 'react'; import { LoadingSpinner } from '@growi/ui/dist/components'; import { format } from 'date-fns/format'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import type { IClearable } from '~/client/interfaces/clearable'; import { toastError } from '~/client/util/toastr'; import type { SupportedActionType } from '~/interfaces/activity'; -import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores-universal/context'; +import { auditLogEnabledAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations'; import { useSWRxActivity } from '~/stores/activity'; import PaginationWrapper from '../PaginationWrapper'; @@ -34,7 +35,7 @@ export const AuditLogManagement: FC = () => { const typeaheadRef = useRef(null); - const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions(); + const auditLogAvailableActionsData = useAtomValue(auditLogAvailableActionsAtom); /* * State @@ -67,7 +68,7 @@ export const AuditLogManagement: FC = () => { toastError('Failed to get Audit Log'); } - const { data: auditLogEnabled } = useAuditLogEnabled(); + const auditLogEnabled = useAtomValue(auditLogEnabledAtom); /* * Functions diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx index 3e1aad0695f..864b5bb5c4e 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState, type JSX } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import ImageCropModal from '~/client/components/Common/ImageCropModal'; @@ -7,7 +8,8 @@ import { apiv3Delete, apiv3PostForm, apiv3Put, } from '~/client/util/apiv3-client'; import { toastError, toastSuccess } from '~/client/util/toastr'; -import { useIsDefaultLogo, useIsCustomizedLogoUploaded } from '~/stores-universal/context'; +import { useIsDefaultLogo } from '~/states/global'; +import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; @@ -18,8 +20,9 @@ const CUSTOMIZED_LOGO = '/attachment/brand-logo'; const CustomizeLogoSetting = (): JSX.Element => { const { t } = useTranslation(); - const { data: isDefaultLogo } = useIsDefaultLogo(); - const { data: isCustomizedLogoUploaded, mutate: mutateIsCustomizedLogoUploaded } = useIsCustomizedLogoUploaded(); + const isDefaultLogo = useIsDefaultLogo(); + const isCustomizedLogoUploaded = useAtomValue(isCustomizedLogoUploadedAtom); + const setIsCustomizedLogoUploaded = useSetAtom(isCustomizedLogoUploadedAtom); const [uploadLogoSrc, setUploadLogoSrc] = useState(null); const [isImageCropModalShow, setIsImageCropModalShow] = useState(false); @@ -35,7 +38,7 @@ const CustomizeLogoSetting = (): JSX.Element => { } }, []); - const onClickSubmit = useCallback(async() => { + const onClickSubmit = useCallback(async () => { try { await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected }); toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' })); @@ -45,10 +48,10 @@ const CustomizeLogoSetting = (): JSX.Element => { } }, [t, isDefaultLogoSelected]); - const onClickDeleteBtn = useCallback(async() => { + const onClickDeleteBtn = useCallback(async () => { try { await apiv3Delete('/customize-setting/delete-brand-logo'); - mutateIsCustomizedLogoUploaded(false); + setIsCustomizedLogoUploaded(false); toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' })); } catch (err) { @@ -56,15 +59,15 @@ const CustomizeLogoSetting = (): JSX.Element => { setRetrieveError(err); throw new Error('Failed to delete logo'); } - }, [mutateIsCustomizedLogoUploaded, t]); + }, [setIsCustomizedLogoUploaded, t]); - const processImageCompletedHandler = useCallback(async(croppedImage) => { + const processImageCompletedHandler = useCallback(async (croppedImage) => { try { const formData = new FormData(); formData.append('file', croppedImage); await apiv3PostForm('/customize-setting/upload-brand-logo', formData); - mutateIsCustomizedLogoUploaded(true); + setIsCustomizedLogoUploaded(true); toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' })); } catch (err) { @@ -72,7 +75,7 @@ const CustomizeLogoSetting = (): JSX.Element => { setRetrieveError(err); throw new Error('Failed to upload brand logo'); } - }, [mutateIsCustomizedLogoUploaded, t]); + }, [setIsCustomizedLogoUploaded, t]); return ( @@ -113,13 +116,13 @@ const CustomizeLogoSetting = (): JSX.Element => { onChange={() => { setIsDefaultLogoSelected(false) }} />
{isCustomizedLogoUploaded && ( @@ -128,7 +131,7 @@ const CustomizeLogoSetting = (): JSX.Element => {

)} @@ -136,7 +139,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
diff --git a/apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx b/apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx index cbe27632790..f3eae88d7d5 100644 --- a/apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx +++ b/apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx @@ -7,7 +7,7 @@ import { Card, CardBody } from 'reactstrap'; import { apiv3Put } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useCustomizeTitle } from '~/stores-universal/context'; +import { useCustomTitleTemplate } from '~/states/global'; import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow'; @@ -15,7 +15,7 @@ export const CustomizeTitle: FC = () => { const { t } = useTranslation('admin'); - const { data: customizeTitle } = useCustomizeTitle(); + const customTitleTemplate = useCustomTitleTemplate(); const { register, @@ -26,9 +26,9 @@ export const CustomizeTitle: FC = () => { // Sync form with store data useEffect(() => { reset({ - customizeTitle: customizeTitle ?? '', + customizeTitle: customTitleTemplate ?? '', }); - }, [customizeTitle, reset]); + }, [customTitleTemplate, reset]); const onSubmit = useCallback(async(data) => { try { diff --git a/apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx b/apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx index ac95afa1799..54e5fca456d 100644 --- a/apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx +++ b/apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState, useCallback } from 'react'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'next-i18next'; - import { apiv3Get, apiv3Post, apiv3Put } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import { SocketEventName } from '~/interfaces/websocket'; -import { useIsSearchServiceReachable } from '~/stores-universal/context'; -import { useAdminSocket } from '~/stores/socket-io'; +import { isSearchServiceReachableAtom } from '~/states/server-configurations'; import NormalizeIndicesControls from './NormalizeIndicesControls'; import RebuildIndexControls from './RebuildIndexControls'; @@ -16,8 +16,9 @@ import StatusTable from './StatusTable'; const ElasticsearchManagement = (): JSX.Element => { const { t } = useTranslation('admin'); - const { data: isSearchServiceReachable } = useIsSearchServiceReachable(); - const { data: socket } = useAdminSocket(); + // Get search service reachable flag from atom + const isSearchServiceReachable = useAtomValue(isSearchServiceReachableAtom); + const socket = useAdminSocket(); const [isInitialized, setIsInitialized] = useState(false); @@ -77,7 +78,7 @@ const ElasticsearchManagement = (): JSX.Element => { if (socket == null) { return; } - socket.on(SocketEventName.AddPageProgress, (data) => { + socket.on(SocketEventName.AddPageProgress, () => { setIsRebuildingProcessing(true); }); diff --git a/apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx b/apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx index 405b9a61113..ec6808d3d89 100644 --- a/apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx +++ b/apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx @@ -3,8 +3,8 @@ import React from 'react'; import { useTranslation } from 'next-i18next'; import PropTypes from 'prop-types'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import { SocketEventName } from '~/interfaces/websocket'; -import { useAdminSocket } from '~/stores/socket-io'; import LabeledProgressBar from '../Common/LabeledProgressBar'; @@ -99,7 +99,7 @@ class RebuildIndexControls extends React.Component { const RebuildIndexControlsFC = (props) => { const { t } = useTranslation('admin'); - const { data: socket } = useAdminSocket(); + const socket = useAdminSocket(); return ; }; diff --git a/apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx b/apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx index 5a8a384cc3e..24968b6fff6 100644 --- a/apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx +++ b/apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { apiDelete } from '~/client/util/apiv1-client'; import { apiv3Get } from '~/client/util/apiv3-client'; import { toastError, toastSuccess } from '~/client/util/toastr'; -import { useAdminSocket } from '~/stores/socket-io'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import LabeledProgressBar from './Common/LabeledProgressBar'; import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable'; @@ -20,7 +20,7 @@ const IGNORED_COLLECTION_NAMES = [ ]; const ExportArchiveDataPage = (): JSX.Element => { - const { data: socket } = useAdminSocket(); + const socket = useAdminSocket(); const { t } = useTranslation('admin'); const [collections, setCollections] = useState([]); diff --git a/apps/app/src/client/components/Admin/G2GDataTransfer.tsx b/apps/app/src/client/components/Admin/G2GDataTransfer.tsx index 768e854fb25..c4444177155 100644 --- a/apps/app/src/client/components/Admin/G2GDataTransfer.tsx +++ b/apps/app/src/client/components/Admin/G2GDataTransfer.tsx @@ -7,9 +7,9 @@ import { useTranslation } from 'next-i18next'; import { useGenerateTransferKey } from '~/client/services/g2g-transfer'; import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client'; import { toastError, toastSuccess } from '~/client/util/toastr'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer'; -import { useGrowiDocumentationUrl } from '~/stores-universal/context'; -import { useAdminSocket } from '~/stores/socket-io'; +import { useGrowiDocumentationUrl } from '~/states/context'; import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard'; @@ -22,7 +22,7 @@ const IGNORED_COLLECTION_NAMES = [ ]; const G2GDataTransfer = (): JSX.Element => { - const { data: socket } = useAdminSocket(); + const socket = useAdminSocket(); const { t } = useTranslation(['admin', 'commons']); const [startTransferKey, setStartTransferKey] = useState(''); @@ -124,7 +124,7 @@ const G2GDataTransfer = (): JSX.Element => { } }, [setTransferring, startTransferKey, selectedCollections, optionsMap]); - const { data: documentationUrl } = useGrowiDocumentationUrl(); + const documentationUrl = useGrowiDocumentationUrl(); // File upload // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => { diff --git a/apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx b/apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx index 311f95d84ca..db074fd678c 100644 --- a/apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx +++ b/apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx @@ -5,10 +5,10 @@ import PropTypes from 'prop-types'; import { apiv3Post } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; +import { useAdminSocket } from '~/features/admin/states/socket-io'; import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option'; import { ImportOptionForPages } from '~/models/admin/import-option-for-pages'; import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions'; -import { useAdminSocket } from '~/stores/socket-io'; import ErrorViewer from './ErrorViewer'; @@ -507,7 +507,7 @@ ImportForm.propTypes = { const ImportFormWrapperFc = (props) => { const { t } = useTranslation('admin'); - const { data: socket } = useAdminSocket(); + const socket = useAdminSocket(); if (socket == null) { return; diff --git a/apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx b/apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx index f37d4798d60..ce626c3098d 100644 --- a/apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx +++ b/apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx @@ -1,280 +1,18 @@ -import React, { useEffect } from 'react'; - -import { useTranslation } from 'next-i18next'; -import PropTypes from 'prop-types'; -import { useForm } from 'react-hook-form'; - -import AdminImportContainer from '~/client/services/AdminImportContainer'; -import { toastError } from '~/client/util/toastr'; -import { toArrayIfNot } from '~/utils/array-utils'; -import loggerFactory from '~/utils/logger'; - import { withUnstatedContainers } from '../../UnstatedUtils'; import GrowiArchiveSection from './GrowiArchiveSection'; -const logger = loggerFactory('growi:importer'); - -const ImportDataPageContents = ({ t, adminImportContainer }) => { - const { register: registerEsa, reset: resetEsa, handleSubmit: handleSubmitEsa } = useForm(); - const { register: registerQiita, reset: resetQiita, handleSubmit: handleSubmitQiita } = useForm(); - - useEffect(() => { - resetEsa({ - esaTeamName: adminImportContainer.state.esaTeamName || '', - esaAccessToken: adminImportContainer.state.esaAccessToken || '', - }); - }, [resetEsa, adminImportContainer.state.esaTeamName, adminImportContainer.state.esaAccessToken]); - - useEffect(() => { - resetQiita({ - qiitaTeamName: adminImportContainer.state.qiitaTeamName || '', - qiitaAccessToken: adminImportContainer.state.qiitaAccessToken || '', - }); - }, [resetQiita, adminImportContainer.state.qiitaTeamName, adminImportContainer.state.qiitaAccessToken]); - +const ImportDataPageContents = () => { return (
- -
-
-

{t('importer_management.import_from', { from: 'esa.io' })}

- - - - - - - - - - - - - - - - - - - - - - - - - -
esa.ioGROWI
{t('importer_management.article')}arrow_circle_right{t('importer_management.page')}
{t('importer_management.category')}arrow_circle_right{t('importer_management.page_path')}
{t('User')}(TBD)
- -
-
    -
  • {t('importer_management.page_skip')}
  • -
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
-
- -
-
- - - - - -
-
-
-
- -
-
-

{t('importer_management.import_from', { from: 'Qiita:Team' })}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Qiita:TeamGROWI
{t('importer_management.article')}arrow_circle_right{t('importer_management.page')}
{t('importer_management.tag')}-
{t('importer_management.Directory_hierarchy_tag')}(TBD)
{t('User')}(TBD)
-
-
    -
  • {t('importer_management.page_skip')}
  • -
-
- -
- -
-
- -
- -
-
- -
- -
- -
-
- - -
-
- - - - - - -
-
- - -
- - -
); }; -ImportDataPageContents.propTypes = { - t: PropTypes.func.isRequired, - adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired, -}; - -const ImportDataPageContentsWrapperFc = (props) => { - const { t } = useTranslation('admin'); - - const { adminImportContainer } = props; - - useEffect(() => { - const fetchImportSettingsData = async() => { - await adminImportContainer.retrieveImportSettingsData(); - }; - - try { - fetchImportSettingsData(); - } - catch (err) { - const errs = toArrayIfNot(err); - toastError(errs); - logger.error(errs); - } - }, [adminImportContainer]); - - return ; -}; - /** * Wrapper component for using unstated */ -const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContentsWrapperFc, [AdminImportContainer]); +const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContents, []); export default ImportDataPageContentsWrapper; diff --git a/apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx b/apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx index 9230ab058c6..7b604cef645 100644 --- a/apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx +++ b/apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useEffect, useState, type JSX, } from 'react'; +import { useAtomValue } from 'jotai'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -9,7 +10,7 @@ import { useRouter } from 'next/router'; import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification'; import { apiv3Post } from '~/client/util/apiv3-client'; import { toastError } from '~/client/util/toastr'; -import { useIsMailerSetup } from '~/stores-universal/context'; +import { isMailerSetupAtom } from '~/states/server-configurations'; import { useSWRxGlobalNotification } from '~/stores/global-notification'; import loggerFactory from '~/utils/logger'; @@ -106,7 +107,8 @@ const ManageGlobalNotification = (props: Props): JSX.Element => { }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]); - const { data: isMailerSetup } = useIsMailerSetup(); + // Mailer setup status (unused yet but kept for potential conditional logic) + const isMailerSetup = useAtomValue(isMailerSetupAtom); const { t } = useTranslation('admin'); return ( diff --git a/apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx b/apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx similarity index 88% rename from apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx rename to apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx index 7474880988d..2435abc070d 100644 --- a/apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx +++ b/apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect } from 'react'; import { pathUtils } from '@growi/core/dist/utils'; import { useTranslation } from 'next-i18next'; -import PropTypes from 'prop-types'; import { useForm } from 'react-hook-form'; import urljoin from 'url-join'; @@ -11,15 +10,23 @@ import urljoin from 'url-join'; import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useSiteUrl } from '~/stores-universal/context'; +import { useSiteUrlWithEmptyValueWarn } from '~/states/global'; import { withUnstatedContainers } from '../../UnstatedUtils'; -const GitHubSecurityManagementContents = (props) => { +type Props = { + adminGeneralSecurityContainer: AdminGeneralSecurityContainer + adminGitHubSecurityContainer: AdminGitHubSecurityContainer +}; + +const GitHubSecurityManagementContents = (props: Props) => { const { - t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl, + adminGeneralSecurityContainer, adminGitHubSecurityContainer, } = props; + const { t } = useTranslation('admin'); + const siteUrl = useSiteUrlWithEmptyValueWarn(); + const { isGitHubEnabled } = adminGeneralSecurityContainer.state; const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state; const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback'); @@ -36,9 +43,11 @@ const GitHubSecurityManagementContents = (props) => { const onClickSubmit = useCallback(async(data) => { try { - await adminGitHubSecurityContainer.changeGitHubClientId(data.githubClientId ?? ''); - await adminGitHubSecurityContainer.changeGitHubClientSecret(data.githubClientSecret ?? ''); - await adminGitHubSecurityContainer.updateGitHubSetting(); + await adminGitHubSecurityContainer.updateGitHubSetting({ + githubClientId: data.githubClientId ?? '', + githubClientSecret: data.githubClientSecret ?? '', + isSameUsernameTreatedAsIdenticalUser: adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser, + }); await adminGeneralSecurityContainer.retrieveSetupStratedies(); toastSuccess(t('security_settings.OAuth.GitHub.updated_github')); } @@ -190,23 +199,10 @@ const GitHubSecurityManagementContents = (props) => { ); }; -GitHubSecurityManagementContents.propTypes = { - t: PropTypes.func.isRequired, // i18next - adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired, - adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired, - siteUrl: PropTypes.string, -}; - -const GitHubSecurityManagementContentsFC = (props) => { - const { t } = useTranslation('admin'); - const { data: siteUrl } = useSiteUrl(); - return ; -}; - /** * Wrapper component for using unstated */ -const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContentsFC, [ +const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContents, [ AdminGeneralSecurityContainer, AdminGitHubSecurityContainer, ]); diff --git a/apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx b/apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx similarity index 88% rename from apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx rename to apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx index 8dc8dacb1f4..7fa72b45262 100644 --- a/apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx +++ b/apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx @@ -1,23 +1,31 @@ +/* eslint-disable react/no-danger */ import React, { useCallback, useEffect } from 'react'; import { pathUtils } from '@growi/core/dist/utils'; import { useTranslation } from 'next-i18next'; -import PropTypes from 'prop-types'; import { useForm } from 'react-hook-form'; import urljoin from 'url-join'; import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useSiteUrl } from '~/stores-universal/context'; +import { useSiteUrlWithEmptyValueWarn } from '~/states/global'; import { withUnstatedContainers } from '../../UnstatedUtils'; -const GoogleSecurityManagementContents = (props) => { +type Props = { + adminGeneralSecurityContainer: AdminGeneralSecurityContainer + adminGoogleSecurityContainer: AdminGoogleSecurityContainer +}; + +const GoogleSecurityManagementContents = (props: Props) => { const { - t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl, + adminGeneralSecurityContainer, adminGoogleSecurityContainer, } = props; + const { t } = useTranslation('admin'); + const siteUrl = useSiteUrlWithEmptyValueWarn(); + const { isGoogleEnabled } = adminGeneralSecurityContainer.state; const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state; const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback'); @@ -34,9 +42,11 @@ const GoogleSecurityManagementContents = (props) => { const onClickSubmit = useCallback(async(data) => { try { - await adminGoogleSecurityContainer.changeGoogleClientId(data.googleClientId ?? ''); - await adminGoogleSecurityContainer.changeGoogleClientSecret(data.googleClientSecret ?? ''); - await adminGoogleSecurityContainer.updateGoogleSetting(); + await adminGoogleSecurityContainer.updateGoogleSetting({ + googleClientId: data.googleClientId ?? '', + googleClientSecret: data.googleClientSecret ?? '', + isSameEmailTreatedAsIdenticalUser: adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser, + }); await adminGeneralSecurityContainer.retrieveSetupStratedies(); toastSuccess(t('security_settings.OAuth.Google.updated_google')); } @@ -191,20 +201,7 @@ const GoogleSecurityManagementContents = (props) => { ); }; -GoogleSecurityManagementContents.propTypes = { - t: PropTypes.func.isRequired, // i18next - adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired, - adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired, - siteUrl: PropTypes.string, -}; - -const GoogleSecurityManagementContentsFc = (props) => { - const { t } = useTranslation('admin'); - const { data: siteUrl } = useSiteUrl(); - return ; -}; - -const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [ +const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContents, [ AdminGeneralSecurityContainer, AdminGoogleSecurityContainer, ]); diff --git a/apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx b/apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx index b5fd949025a..743e41b0087 100644 --- a/apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx +++ b/apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx @@ -56,17 +56,20 @@ const LdapSecuritySettingContents = (props: Props) => { const onSubmit = useCallback(async(data) => { try { - await adminLdapSecurityContainer.changeServerUrl(data.serverUrl); - await adminLdapSecurityContainer.changeBindDN(data.ldapBindDN); - await adminLdapSecurityContainer.changeBindDNPassword(data.ldapBindDNPassword); - await adminLdapSecurityContainer.changeSearchFilter(data.ldapSearchFilter); - await adminLdapSecurityContainer.changeAttrMapUsername(data.ldapAttrMapUsername); - await adminLdapSecurityContainer.changeAttrMapMail(data.ldapAttrMapMail); - await adminLdapSecurityContainer.changeAttrMapName(data.ldapAttrMapName); - await adminLdapSecurityContainer.changeGroupSearchBase(data.ldapGroupSearchBase); - await adminLdapSecurityContainer.changeGroupSearchFilter(data.ldapGroupSearchFilter); - await adminLdapSecurityContainer.changeGroupDnProperty(data.ldapGroupDnProperty); - await adminLdapSecurityContainer.updateLdapSetting(); + await adminLdapSecurityContainer.updateLdapSetting({ + serverUrl: data.serverUrl, + isUserBind: adminLdapSecurityContainer.state.isUserBind, + ldapBindDN: data.ldapBindDN, + ldapBindDNPassword: data.ldapBindDNPassword, + ldapSearchFilter: data.ldapSearchFilter, + ldapAttrMapUsername: data.ldapAttrMapUsername, + isSameUsernameTreatedAsIdenticalUser: adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser, + ldapAttrMapMail: data.ldapAttrMapMail, + ldapAttrMapName: data.ldapAttrMapName, + ldapGroupSearchBase: data.ldapGroupSearchBase, + ldapGroupSearchFilter: data.ldapGroupSearchFilter, + ldapGroupDnProperty: data.ldapGroupDnProperty, + }); await adminGeneralSecurityContainer.retrieveSetupStratedies(); toastSuccess(t('security_settings.ldap.updated_ldap')); } diff --git a/apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx b/apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx index f42f4dab685..f764134ae0d 100644 --- a/apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx +++ b/apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx @@ -1,13 +1,13 @@ import React, { useCallback, useEffect } from 'react'; - -import { useTranslation } from 'next-i18next'; import Link from 'next/link'; +import { useTranslation } from 'next-i18next'; +import { useAtomValue } from 'jotai'; import { useForm } from 'react-hook-form'; import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useIsMailerSetup } from '~/stores-universal/context'; +import { isMailerSetupAtom } from '~/states/server-configurations'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -23,7 +23,7 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => { } = props; const { t } = useTranslation('admin'); - const { data: isMailerSetup = false } = useIsMailerSetup(); + const isMailerSetup = useAtomValue(isMailerSetupAtom); const { register, handleSubmit, reset } = useForm(); @@ -38,8 +38,12 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => { const onSubmit = useCallback(async(data) => { try { - await adminLocalSecurityContainer.changeRegistrationWhitelist(data.registrationWhitelist); - await adminLocalSecurityContainer.updateLocalSecuritySetting(); + await adminLocalSecurityContainer.updateLocalSecuritySetting({ + registrationMode: adminLocalSecurityContainer.state.registrationMode, + registrationWhitelist: data.registrationWhitelist.split('\n'), + isPasswordResetEnabled: adminLocalSecurityContainer.state.isPasswordResetEnabled, + isEmailAuthenticationEnabled: adminLocalSecurityContainer.state.isEmailAuthenticationEnabled, + }); await adminGeneralSecurityContainer.retrieveSetupStratedies(); toastSuccess(t('security_settings.updated_general_security_setting')); } diff --git a/apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx b/apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx index a70fbbcd8b9..7a1813081a6 100644 --- a/apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx +++ b/apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx @@ -9,7 +9,7 @@ import urljoin from 'url-join'; import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useSiteUrl } from '~/stores-universal/context'; +import { useSiteUrlWithEmptyValueWarn } from '~/states/global'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -20,7 +20,7 @@ type Props = { const OidcSecurityManagementContents = (props: Props) => { const { t } = useTranslation('admin'); - const { data: siteUrl } = useSiteUrl(); + const siteUrl = useSiteUrlWithEmptyValueWarn(); const { adminGeneralSecurityContainer, adminOidcSecurityContainer, @@ -33,10 +33,7 @@ const OidcSecurityManagementContents = (props: Props) => { oidcAttrMapId, oidcAttrMapUserName, oidcAttrMapName, oidcAttrMapEmail, } = adminOidcSecurityContainer.state; - const oidcCallbackUrl = urljoin( - siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl), - '/passport/oidc/callback', - ); + const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback'); const { register, handleSubmit, reset } = useForm(); @@ -68,23 +65,26 @@ const OidcSecurityManagementContents = (props: Props) => { const onSubmit = useCallback(async(data) => { try { - await adminOidcSecurityContainer.changeOidcProviderName(data.oidcProviderName); - await adminOidcSecurityContainer.changeOidcIssuerHost(data.oidcIssuerHost); - await adminOidcSecurityContainer.changeOidcClientId(data.oidcClientId); - await adminOidcSecurityContainer.changeOidcClientSecret(data.oidcClientSecret); - await adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(data.oidcAuthorizationEndpoint); - await adminOidcSecurityContainer.changeOidcTokenEndpoint(data.oidcTokenEndpoint); - await adminOidcSecurityContainer.changeOidcRevocationEndpoint(data.oidcRevocationEndpoint); - await adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(data.oidcIntrospectionEndpoint); - await adminOidcSecurityContainer.changeOidcUserInfoEndpoint(data.oidcUserInfoEndpoint); - await adminOidcSecurityContainer.changeOidcEndSessionEndpoint(data.oidcEndSessionEndpoint); - await adminOidcSecurityContainer.changeOidcRegistrationEndpoint(data.oidcRegistrationEndpoint); - await adminOidcSecurityContainer.changeOidcJWKSUri(data.oidcJWKSUri); - await adminOidcSecurityContainer.changeOidcAttrMapId(data.oidcAttrMapId); - await adminOidcSecurityContainer.changeOidcAttrMapUserName(data.oidcAttrMapUserName); - await adminOidcSecurityContainer.changeOidcAttrMapName(data.oidcAttrMapName); - await adminOidcSecurityContainer.changeOidcAttrMapEmail(data.oidcAttrMapEmail); - await adminOidcSecurityContainer.updateOidcSetting(); + await adminOidcSecurityContainer.updateOidcSetting({ + oidcProviderName: data.oidcProviderName, + oidcIssuerHost: data.oidcIssuerHost, + oidcClientId: data.oidcClientId, + oidcClientSecret: data.oidcClientSecret, + oidcAuthorizationEndpoint: data.oidcAuthorizationEndpoint, + oidcTokenEndpoint: data.oidcTokenEndpoint, + oidcRevocationEndpoint: data.oidcRevocationEndpoint, + oidcIntrospectionEndpoint: data.oidcIntrospectionEndpoint, + oidcUserInfoEndpoint: data.oidcUserInfoEndpoint, + oidcEndSessionEndpoint: data.oidcEndSessionEndpoint, + oidcRegistrationEndpoint: data.oidcRegistrationEndpoint, + oidcJWKSUri: data.oidcJWKSUri, + oidcAttrMapId: data.oidcAttrMapId, + oidcAttrMapUserName: data.oidcAttrMapUserName, + oidcAttrMapName: data.oidcAttrMapName, + oidcAttrMapEmail: data.oidcAttrMapEmail, + isSameUsernameTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser, + isSameEmailTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser, + }); await adminGeneralSecurityContainer.retrieveSetupStratedies(); toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc')); } diff --git a/apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx b/apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx index e11b60e2f3b..e37db516775 100644 --- a/apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx +++ b/apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx @@ -11,7 +11,7 @@ import urljoin from 'url-join'; import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer'; import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useSiteUrl } from '~/stores-universal/context'; +import { useSiteUrlWithEmptyValueWarn } from '~/states/global'; import { withUnstatedContainers } from '../../UnstatedUtils'; @@ -26,7 +26,7 @@ const SamlSecurityManagementContents = (props: Props) => { } = props; const { t } = useTranslation('admin'); - const { data: siteUrl } = useSiteUrl(); + const siteUrl = useSiteUrlWithEmptyValueWarn(); const [isHelpOpened, setIsHelpOpened] = useState(false); const { register, handleSubmit, reset } = useForm(); @@ -46,18 +46,20 @@ const SamlSecurityManagementContents = (props: Props) => { }, [adminSamlSecurityContainer.state, reset]); const onSubmit = useCallback(async(data) => { - adminSamlSecurityContainer.changeSamlEntryPoint(data.samlEntryPoint); - adminSamlSecurityContainer.changeSamlIssuer(data.samlIssuer); - adminSamlSecurityContainer.changeSamlCert(data.samlCert); - adminSamlSecurityContainer.changeSamlAttrMapId(data.samlAttrMapId); - adminSamlSecurityContainer.changeSamlAttrMapUserName(data.samlAttrMapUsername); - adminSamlSecurityContainer.changeSamlAttrMapMail(data.samlAttrMapMail); - adminSamlSecurityContainer.changeSamlAttrMapFirstName(data.samlAttrMapFirstName); - adminSamlSecurityContainer.changeSamlAttrMapLastName(data.samlAttrMapLastName); - adminSamlSecurityContainer.changeSamlABLCRule(data.samlABLCRule); - try { - await adminSamlSecurityContainer.updateSamlSetting(); + await adminSamlSecurityContainer.updateSamlSetting({ + samlEntryPoint: data.samlEntryPoint, + samlIssuer: data.samlIssuer, + samlCert: data.samlCert, + samlAttrMapId: data.samlAttrMapId, + samlAttrMapUsername: data.samlAttrMapUsername, + samlAttrMapMail: data.samlAttrMapMail, + samlAttrMapFirstName: data.samlAttrMapFirstName, + samlAttrMapLastName: data.samlAttrMapLastName, + isSameUsernameTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser, + isSameEmailTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser, + samlABLCRule: data.samlABLCRule, + }); toastSuccess(t('security_settings.SAML.updated_saml')); } catch (err) { @@ -75,10 +77,7 @@ const SamlSecurityManagementContents = (props: Props) => { const { useOnlyEnvVars } = adminSamlSecurityContainer.state; const { isSamlEnabled } = adminGeneralSecurityContainer.state; - const samlCallbackUrl = urljoin( - siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl), - '/passport/saml/callback', - ); + const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback'); return ( diff --git a/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx b/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx index f12eef8df6f..ac04ce83f5c 100644 --- a/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx +++ b/apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx @@ -36,10 +36,21 @@ const SecuritySettingComponent: React.FC = ({ adminGeneralSecurityContain const onSubmit = useCallback(async(data: FormData) => { try { - // Update sessionMaxAge from form data - await adminGeneralSecurityContainer.setSessionMaxAge(data.sessionMaxAge); - // Save all security settings - await adminGeneralSecurityContainer.updateGeneralSecuritySetting(); + // Save all security settings with form data + await adminGeneralSecurityContainer.updateGeneralSecuritySetting({ + sessionMaxAge: data.sessionMaxAge, + restrictGuestMode: adminGeneralSecurityContainer.state.currentRestrictGuestMode, + pageDeletionAuthority: adminGeneralSecurityContainer.state.currentPageDeletionAuthority, + pageCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, + pageRecursiveDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority, + pageRecursiveCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority, + isAllGroupMembershipRequiredForPageCompleteDeletion: adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion, + hideRestrictedByGroup: adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden', + hideRestrictedByOwner: adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden', + isUsersHomepageDeletionEnabled: adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled, + isForceDeleteUserHomepageOnUserDeletion: adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion, + isRomUserAllowedToComment: adminGeneralSecurityContainer.state.isRomUserAllowedToComment, + }); toastSuccess(t('security_settings.updated_general_security_setting')); } catch (err) { diff --git a/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx b/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx index dfe58ca90b0..40759cbd181 100644 --- a/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx +++ b/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useAppTitle } from '~/stores-universal/context'; +import { useAppTitle } from '~/states/global'; import loggerFactory from '~/utils/logger'; @@ -26,7 +26,7 @@ const CustomBotWithProxySettings = (props) => { const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null); const [siteName, setSiteName] = useState(''); const { t } = useTranslation(); - const { data: appTitle } = useAppTitle(); + const appTitle = useAppTitle(); // componentDidUpdate useEffect(() => { diff --git a/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx b/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx index 82c09161f84..3cb09ad58dc 100644 --- a/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx +++ b/apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'next-i18next'; import PropTypes from 'prop-types'; -import { useAppTitle } from '~/stores-universal/context'; +import { useAppTitle } from '~/states/global'; import { CustomBotWithoutProxyConnectionStatus } from './CustomBotWithoutProxyConnectionStatus'; import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion'; @@ -11,7 +11,7 @@ import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './C const CustomBotWithoutProxySettings = (props) => { const { connectionStatuses } = props; const { t } = useTranslation(); - const { data: appTitle } = useAppTitle(); + const appTitle = useAppTitle(); const [siteName, setSiteName] = useState(''); useEffect(() => { diff --git a/apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx b/apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx index 025a92a061e..2790cd5d075 100644 --- a/apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx +++ b/apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'next-i18next'; import { @@ -29,53 +29,70 @@ export const DeleteSlackBotSettingsModal = React.memo((props: DeleteSlackBotSett onClose?.(); }, [onClose]); + // Memoize conditional content + const headerContent = useMemo(() => { + if (isResetAll) { + return ( + <> + delete_forever + {t('admin:slack_integration.reset_all_settings')} + + ); + } + return ( + <> + delete + {t('admin:slack_integration.delete_slackbot_settings')} + + ); + }, [isResetAll, t]); + + const bodyContent = useMemo(() => { + const htmlContent = isResetAll + ? t('admin:slack_integration.all_settings_of_the_bot_will_be_reset') + : t('admin:slack_integration.slackbot_settings_notice'); + return ( + + ); + }, [isResetAll, t]); + + const deleteButtonContent = useMemo(() => { + if (isResetAll) { + return ( + <> + delete_forever + {t('admin:slack_integration.reset')} + + ); + } + return ( + <> + delete + {t('admin:slack_integration.delete')} + + ); + }, [isResetAll, t]); + + // Early return optimization + if (!isOpen) { + return <>; + } + return ( - - {isResetAll && ( - <> - delete_forever - {t('admin:slack_integration.reset_all_settings')} - - )} - {!isResetAll && ( - <> - delete - {t('admin:slack_integration.delete_slackbot_settings')} - - )} - + {headerContent} - {isResetAll && ( - - )} - {!isResetAll && ( - - )} + {bodyContent} diff --git a/apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx b/apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx index 0f257274ec9..b9f0283d6fc 100644 --- a/apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx +++ b/apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useAppTitle } from '~/stores-universal/context'; +import { useAppTitle } from '~/states/global'; import loggerFactory from '~/utils/logger'; @@ -27,7 +27,7 @@ const OfficialBotSettings = (props) => { const [siteName, setSiteName] = useState(''); const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null); const { t } = useTranslation(); - const { data: appTitle } = useAppTitle(); + const appTitle = useAppTitle(); const addSlackAppIntegrationHandler = async() => { if (onClickAddSlackWorkspaceBtn != null) { diff --git a/apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx b/apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx index b75f286b522..0036378580a 100644 --- a/apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx +++ b/apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client'; import { toastSuccess, toastError } from '~/client/util/toastr'; -import { useSiteUrl } from '~/stores-universal/context'; +import { useSiteUrlWithEmptyValueWarn } from '~/states/global'; import loggerFactory from '~/utils/logger'; import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard'; @@ -286,7 +286,7 @@ const TestProcess = ({ const WithProxyAccordions = (props) => { const { t } = useTranslation(); - const { data: siteUrl } = useSiteUrl(); + const siteUrl = useSiteUrlWithEmptyValueWarn(); const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false); const submitForm = () => { diff --git a/apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx b/apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx index d8d52568570..f5edb90fd47 100644 --- a/apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx +++ b/apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx @@ -1,5 +1,7 @@ import type { FC } from 'react'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { + useState, useEffect, useCallback, useMemo, +} from 'react'; import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core'; import { useTranslation } from 'next-i18next'; @@ -16,12 +18,12 @@ type Props = { isExternalGroup?: boolean }; -export const UserGroupModal: FC = (props: Props) => { +const UserGroupModalSubstance: FC = (props: Props) => { const { t } = useTranslation('admin'); const { - userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false, + userGroup, buttonLabel, onClickSubmit, onHide, isExternalGroup = false, } = props; /* @@ -42,6 +44,14 @@ export const UserGroupModal: FC = (props: Props) => { setDescription(e.target.value); }, []); + // Memoized user group data for submission + const userGroupData = useMemo(() => ({ + _id: userGroup?._id, + name: currentName, + description: currentDescription, + parent: currentParent, + }), [userGroup?._id, currentName, currentDescription, currentParent]); + const onSubmitHandler = useCallback(async(e) => { e.preventDefault(); // no reload @@ -49,13 +59,8 @@ export const UserGroupModal: FC = (props: Props) => { return; } - await onClickSubmit({ - _id: userGroup?._id, - name: currentName, - description: currentDescription, - parent: currentParent, - }); - }, [userGroup, currentName, currentDescription, currentParent, onClickSubmit]); + await onClickSubmit(userGroupData); + }, [onClickSubmit, userGroupData]); // componentDidMount useEffect(() => { @@ -66,58 +71,68 @@ export const UserGroupModal: FC = (props: Props) => { } }, [userGroup]); + return ( +
+ + {t('user_group_management.basic_info')} + + + +
+ + +
+ +
+ +