فهرست منبع

WIP: refactor AiAssistantManagementPageTreeSelection

Yuki Takei 5 ماه پیش
والد
کامیت
1cec7a585c

+ 82 - 0
.serena/memories/apps-app-ai-assistant-page-tree-selection-refactoring-plan.md

@@ -0,0 +1,82 @@
+# AiAssistantManagementPageTreeSelection リファクタリング完了
+
+## 実装サマリー
+
+### 完了日時
+2024年 - リファクタリング完了
+
+### 変更内容
+
+#### 1. SimplifiedItemsTree.tsx (features/page-tree/components/)
+**追加機能**: checkboxes オプションのサポート
+
+新しい Props:
+- `enableCheckboxes?: boolean` - チェックボックス機能の有効化
+- `initialCheckedItems?: string[]` - 初期チェック済みアイテム(ID配列)
+- `onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void` - チェック変更時のコールバック
+
+実装詳細:
+- `@headless-tree/core` の `checkboxesFeature` を条件付きで追加
+- `propagateCheckedState: false` で子への伝播を無効化
+- `canCheckFolders: true` でフォルダもチェック可能
+- `useEffect` で checkedItems の変更を監視し、親にページ情報を通知
+
+#### 2. SimplifiedTreeItemWithCheckbox.tsx (新規作成)
+**場所**: features/openai/client/components/AiAssistant/AiAssistantManagementModal/
+
+AI Assistant のページ選択ツリー用のカスタムツリーアイテムコンポーネント:
+- `TreeItemLayout` を使用
+- `item.getCheckboxProps()` でチェックボックスの状態を取得
+- `customEndComponents` にチェックボックスを配置
+
+#### 3. AiAssistantManagementPageTreeSelection.tsx (リファクタリング)
+**変更点**:
+- 旧 `ItemsTree` → 新 `SimplifiedItemsTree` への移行
+- 旧 `SelectablePageTree` コンポーネント削除(不要に)
+- callback ref パターンで `scrollerElem` を管理
+- チェックボックス変更時に `/*` サフィックスを付加してページを追加
+- 選択解除時にリストからページを削除
+
+### 動作フロー
+
+1. **初期状態**
+   - `baseSelectedPages` から `initialCheckedItems` を計算(ID配列)
+   - 既に選択済みのページは初期チェック状態になる
+
+2. **チェック追加**
+   - ユーザーがチェックボックスをクリック
+   - `onCheckedItemsChange` が呼ばれる
+   - ページパスに `/*` を付加して `selectedPages` に追加
+
+3. **チェック解除**
+   - 現在のチェック状態と `selectedPages` を比較
+   - `selectedPages` にあるが未チェックのものを削除
+
+### degre チェック項目 ✅
+
+- [x] `/*` 付加ロジックが維持されている
+- [x] 既に選択済みのページは初期チェック状態になる
+- [x] 重複追加が防止される(Set で管理)
+- [x] 選択済みリストからの削除が機能する
+- [x] 「次へ」ボタンの動作が変わらない
+
+### スタイル変更
+
+`AiAssistantManagementPageTreeSelection.module.scss`:
+- `.page-tree-container` 追加(高さ300px、overflow-y: auto)
+- `.tree-item-checkbox` 追加(チェックボックスのスタイル)
+
+### 技術的な注意点
+
+1. **initialCheckedItems の依存配列**
+   - useMemo の依存配列に意図的に含めていない
+   - 理由: 毎回再初期化を防ぐため
+   - biome-ignore コメントで抑制
+
+2. **checkedItems の監視**
+   - useEffect で tree.getState().checkedItems を監視
+   - prevCheckedItemsRef で前回値と比較し、変更時のみコールバック実行
+
+### 関連ファイル
+
+- `apps/app/src/features/page-tree/index.ts` - components の export 追加

+ 11 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -8,4 +8,15 @@
       padding: 0.4rem 1rem !important;
     }
   }
+
+  .page-tree-container {
+    height: 300px;
+    overflow-y: auto;
+  }
+}
+
+.tree-item-checkbox {
+  margin-right: 0.5rem;
+  margin-left: auto;
+  cursor: pointer;
 }

+ 78 - 107
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -1,13 +1,11 @@
-import React, { memo, Suspense, useCallback } from 'react';
+import { Suspense, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
 import SimpleBar from 'simplebar-react';
 
-import { ItemsTree } from '~/client/components/ItemsTree';
 import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
-import type { TreeItemProps } from '~/client/components/TreeItem';
-import { TreeItemLayout } from '~/client/components/TreeItem';
-import type { IPageForItem } from '~/interfaces/page';
+import { SimplifiedItemsTree } from '~/features/page-tree/components';
+import type { IPageForTreeItem } from '~/interfaces/page';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 
 import {
@@ -22,82 +20,16 @@ import {
 } from '../../../states/modal/ai-assistant-management';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
 import { SelectablePageList } from './SelectablePageList';
+import {
+  SimplifiedTreeItemWithCheckbox,
+  simplifiedTreeItemWithCheckboxSize,
+} from './SimplifiedTreeItemWithCheckbox';
 
 import styles from './AiAssistantManagementPageTreeSelection.module.scss';
 
 const moduleClass =
   styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
 
-const SelectablePageTree = memo(
-  (props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
-    const { onClickAddPageButton } = props;
-
-    const isGuestUser = useIsGuestUser();
-    const isReadOnlyUser = useIsReadOnlyUser();
-
-    const pageTreeItemClickHandler = useCallback(
-      (page: IPageForItem) => {
-        if (!isSelectablePage(page)) {
-          return;
-        }
-
-        onClickAddPageButton(page);
-      },
-      [onClickAddPageButton],
-    );
-
-    const SelectPageButton = useCallback(
-      ({ page }: { page: IPageForItem }) => {
-        return (
-          <button
-            type="button"
-            className="border-0 rounded btn p-0"
-            onClick={(e) => {
-              e.stopPropagation();
-              pageTreeItemClickHandler(page);
-            }}
-          >
-            <span className="material-symbols-outlined p-0 me-2 text-primary">
-              add_circle
-            </span>
-          </button>
-        );
-      },
-      [pageTreeItemClickHandler],
-    );
-
-    const PageTreeItem = useCallback(
-      (props: TreeItemProps) => {
-        const { itemNode } = props;
-        const { page } = itemNode;
-
-        return (
-          <TreeItemLayout
-            {...props}
-            itemClass={PageTreeItem}
-            className="text-muted"
-            customHoveredEndComponents={[
-              () => <SelectPageButton page={page} />,
-            ]}
-          />
-        );
-      },
-      [SelectPageButton],
-    );
-
-    return (
-      <div className="page-tree-item">
-        <ItemsTree
-          targetPath="/"
-          isEnableActions={!isGuestUser}
-          isReadOnlyUser={!!isReadOnlyUser}
-          CustomTreeItem={PageTreeItem}
-        />
-      </div>
-    );
-  },
-);
-
 type Props = {
   baseSelectedPages: SelectablePage[];
   updateBaseSelectedPages: (pages: SelectablePage[]) => void;
@@ -109,38 +41,60 @@ export const AiAssistantManagementPageTreeSelection = (
   const { baseSelectedPages, updateBaseSelectedPages } = props;
 
   const { t } = useTranslation();
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
   const aiAssistantManagementModalData = useAiAssistantManagementModalStatus();
   const { changePageMode } = useAiAssistantManagementModalActions();
   const isNewAiAssistant =
     aiAssistantManagementModalData?.aiAssistantData == null;
 
-  const {
-    selectedPages,
-    selectedPagesRef,
-    selectedPagesArray,
-    addPage,
-    removePage,
-  } = useSelectedPages(baseSelectedPages);
-
-  const addPageButtonClickHandler = useCallback(
-    (page: SelectablePage) => {
-      const pagePathWithGlob = `${page.path}/*`;
-      if (
-        selectedPagesRef.current == null ||
-        selectedPagesRef.current.has(pagePathWithGlob)
-      ) {
-        return;
-      }
-
-      const clonedPage = { ...page };
-      clonedPage.path = pagePathWithGlob;
-
-      addPage(clonedPage);
+  // Scroll container for virtualization
+  const [scrollerElem, setScrollerElem] = useState<HTMLElement | null>(null);
+
+  const { selectedPages, selectedPagesArray, addPage, removePage } =
+    useSelectedPages(baseSelectedPages);
+
+  // Calculate initial checked items from baseSelectedPages
+  // Remove the /* suffix to match with page IDs
+  const initialCheckedItems = useMemo(() => {
+    return baseSelectedPages
+      .filter((page) => page._id != null)
+      .map((page) => page._id as string);
+  }, [baseSelectedPages]);
+
+  // Handle checked items change from tree
+  const handleCheckedItemsChange = useCallback(
+    (checkedPages: IPageForTreeItem[]) => {
+      // Get current checked page IDs (with /* suffix paths)
+      const currentCheckedPaths = new Set(
+        checkedPages
+          .filter((page) => isSelectablePage(page) && page.path != null)
+          .map((page) => `${page.path}/*`),
+      );
+
+      // Get currently selected page paths
+      const currentSelectedPaths = new Set(selectedPages.keys());
+
+      // Add newly checked pages
+      checkedPages.forEach((page) => {
+        if (!isSelectablePage(page) || page.path == null) {
+          return;
+        }
+        const pagePathWithGlob = `${page.path}/*`;
+        if (!currentSelectedPaths.has(pagePathWithGlob)) {
+          const clonedPage = { ...page, path: pagePathWithGlob };
+          addPage(clonedPage as SelectablePage);
+        }
+      });
+
+      // Remove unchecked pages
+      selectedPagesArray.forEach((page) => {
+        if (page.path != null && !currentCheckedPaths.has(page.path)) {
+          removePage(page);
+        }
+      });
     },
-    [
-      addPage,
-      selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
-    ],
+    [selectedPages, selectedPagesArray, addPage, removePage],
   );
 
   const nextButtonClickHandler = useCallback(() => {
@@ -157,6 +111,11 @@ export const AiAssistantManagementPageTreeSelection = (
     updateBaseSelectedPages,
   ]);
 
+  const estimateTreeItemSize = useCallback(
+    () => simplifiedTreeItemWithCheckboxSize,
+    [],
+  );
+
   return (
     <div className={moduleClass}>
       <AiAssistantManagementHeader
@@ -178,13 +137,25 @@ export const AiAssistantManagementPageTreeSelection = (
           {t('modal_ai_assistant.search_reference_pages_by_keyword')}
         </h4>
 
-        <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <div className="px-4">
-            <SelectablePageTree
-              onClickAddPageButton={addPageButtonClickHandler}
-            />
+        <div className="px-4">
+          <div className="page-tree-container" ref={setScrollerElem}>
+            {scrollerElem != null && (
+              <Suspense fallback={<ItemsTreeContentSkeleton />}>
+                <SimplifiedItemsTree
+                  targetPath="/"
+                  isEnableActions={!isGuestUser}
+                  isReadOnlyUser={!!isReadOnlyUser}
+                  CustomTreeItem={SimplifiedTreeItemWithCheckbox}
+                  estimateTreeItemSize={estimateTreeItemSize}
+                  scrollerElem={scrollerElem}
+                  enableCheckboxes
+                  initialCheckedItems={initialCheckedItems}
+                  onCheckedItemsChange={handleCheckedItemsChange}
+                />
+              </Suspense>
+            )}
           </div>
-        </Suspense>
+        </div>
 
         <h4 className="text-center fw-bold mb-3 mt-4">
           {t('modal_ai_assistant.reference_pages')}

+ 85 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SimplifiedTreeItemWithCheckbox.tsx

@@ -0,0 +1,85 @@
+import type { FC } from 'react';
+
+import type { TreeItemProps } from '~/features/page-tree';
+import { SimpleItemContent } from '~/features/page-tree/components';
+
+import styles from './AiAssistantManagementPageTreeSelection.module.scss';
+
+// Reuse the module class from the parent component
+const checkboxClass = styles['tree-item-checkbox'] ?? '';
+
+export const simplifiedTreeItemWithCheckboxSize = 36; // in px
+
+type SimplifiedTreeItemWithCheckboxProps = TreeItemProps & {
+  key?: React.Key | null;
+};
+
+const indentSize = 10; // in px
+
+export const SimplifiedTreeItemWithCheckbox: FC<
+  SimplifiedTreeItemWithCheckboxProps
+> = (props) => {
+  const { item, onToggle } = props;
+
+  const page = item.getItemData();
+  const itemLevel = item.getItemMeta().level;
+  const hasDescendants = item.isFolder();
+
+  // Get checkbox props from headless-tree
+  const checkboxProps = (item as any).getCheckboxProps?.() ?? {};
+
+  const handleToggleExpand = () => {
+    if (item.isExpanded()) {
+      item.collapse();
+    } else {
+      item.expand();
+    }
+    onToggle?.();
+  };
+
+  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    e.stopPropagation();
+    (item as any).toggleCheckedState?.();
+    onToggle?.();
+  };
+
+  return (
+    <div
+      className="tree-item-layout text-muted"
+      style={{ paddingLeft: `${itemLevel > 0 ? indentSize * itemLevel : 0}px` }}
+    >
+      <li className="list-group-item list-group-item-action border-0 py-0 ps-0 d-flex align-items-center rounded-1">
+        <div
+          className="btn-triangle-container d-flex justify-content-center"
+          style={{ minWidth: '24px' }}
+        >
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`btn p-0 ${item.isExpanded() ? 'open' : ''}`}
+              onClick={handleToggleExpand}
+              style={{
+                border: 0,
+                transition: 'all 0.2s ease-out',
+                transform: item.isExpanded() ? 'rotate(90deg)' : 'rotate(0deg)',
+              }}
+            >
+              <span className="material-symbols-outlined fs-5">
+                arrow_right
+              </span>
+            </button>
+          )}
+        </div>
+
+        <SimpleItemContent page={page} />
+
+        <input
+          type="checkbox"
+          className={`form-check-input ${checkboxClass}`}
+          checked={checkboxProps.checked ?? false}
+          onChange={handleCheckboxChange}
+        />
+      </li>
+    </div>
+  );
+};

+ 47 - 6
apps/app/src/features/page-tree/components/SimplifiedItemsTree.tsx

@@ -1,7 +1,8 @@
 import type { FC } from 'react';
-import { useEffect, useMemo, useRef } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   asyncDataLoaderFeature,
+  checkboxesFeature,
   hotkeysCoreFeature,
   renamingFeature,
   selectionFeature,
@@ -27,14 +28,17 @@ import {
   usePageTreeRevalidationEffect,
 } from '../states/page-tree-update';
 
-// Stable features array to avoid recreating on every render
-const TREE_FEATURES = [
+// Base features for all tree variants
+const BASE_FEATURES = [
   asyncDataLoaderFeature,
   selectionFeature,
   hotkeysCoreFeature,
   renamingFeature,
 ];
 
+// Features with checkboxes support
+const FEATURES_WITH_CHECKBOXES = [...BASE_FEATURES, checkboxesFeature];
+
 // Stable createLoadingItemData function
 const createLoadingItemData = (): IPageForTreeItem => ({
   _id: '',
@@ -55,6 +59,10 @@ type Props = {
   CustomTreeItem: React.FunctionComponent<TreeItemProps>;
   estimateTreeItemSize: () => number;
   scrollerElem?: HTMLElement | null;
+  // Checkbox feature options
+  enableCheckboxes?: boolean;
+  initialCheckedItems?: string[];
+  onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
 };
 
 export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
@@ -67,6 +75,9 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     CustomTreeItem,
     estimateTreeItemSize,
     scrollerElem,
+    enableCheckboxes = false,
+    initialCheckedItems = [],
+    onCheckedItemsChange,
   } = props;
 
   const triggerTreeRebuild = useTriggerTreeRebuild();
@@ -84,11 +95,24 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     useTreeItemHandlers(triggerTreeRebuild);
 
   // Stable initial state
+  // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
   const initialState = useMemo(
-    () => ({ expandedItems: [ROOT_PAGE_VIRTUAL_ID] }),
-    [],
+    () => ({
+      expandedItems: [ROOT_PAGE_VIRTUAL_ID],
+      ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
+    }),
+    [enableCheckboxes],
   );
 
+  // State to track checked items for re-rendering
+  const [checkedItemIds, setCheckedItemIds] =
+    useState<string[]>(initialCheckedItems);
+
+  // Callback to update checked items state (triggers re-render)
+  const handleSetCheckedItems = useCallback((itemIds: string[]) => {
+    setCheckedItemIds(itemIds);
+  }, []);
+
   const tree = useTree<IPageForTreeItem>({
     rootItemId: ROOT_PAGE_VIRTUAL_ID,
     getItemName,
@@ -97,9 +121,26 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
     createLoadingItemData,
     dataLoader,
     onRename: handleRename,
-    features: TREE_FEATURES,
+    features: enableCheckboxes ? FEATURES_WITH_CHECKBOXES : BASE_FEATURES,
+    // Checkbox configuration: prevent folder auto-check to avoid selecting all descendants
+    canCheckFolders: enableCheckboxes,
+    propagateCheckedState: false,
+    // Custom setter to track checked items changes
+    setCheckedItems: enableCheckboxes ? handleSetCheckedItems : undefined,
   });
 
+  // Notify parent when checked items change
+  useEffect(() => {
+    if (!enableCheckboxes || onCheckedItemsChange == null) {
+      return;
+    }
+
+    const checkedPages = checkedItemIds
+      .map((id) => tree.getItemInstance(id)?.getItemData())
+      .filter((page): page is IPageForTreeItem => page != null);
+    onCheckedItemsChange(checkedPages);
+  }, [enableCheckboxes, checkedItemIds, onCheckedItemsChange, tree]);
+
   // Track local generation number
   const localGenerationRef = useRef(1);
   const globalGeneration = usePageTreeInformationGeneration();