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

implement new hook for auto-expand ancestors feature

Yuki Takei 4 месяцев назад
Родитель
Сommit
76785a4e6b

+ 9 - 64
apps/app/src/features/page-tree/client/components/SimplifiedItemsTree.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import { useCallback, useEffect, useRef, useState } from 'react';
-import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
 import {
   asyncDataLoaderFeature,
   hotkeysCoreFeature,
@@ -14,6 +13,7 @@ import type { IPageForTreeItem } from '~/interfaces/page';
 import { useSWRxRootPage } from '~/stores/page-listing';
 
 import { ROOT_PAGE_VIRTUAL_ID } from '../../constants';
+import { useAutoExpandAncestors } from '../hooks/use-auto-expand-ancestors';
 import { useDataLoader } from '../hooks/use-data-loader';
 import { usePageCreate } from '../hooks/use-page-create';
 import { usePageRename } from '../hooks/use-page-rename';
@@ -172,69 +172,14 @@ export const SimplifiedItemsTree: FC<Props> = (props: Props) => {
   }, [items.length]);
 
   // Auto-expand items that are ancestors of targetPath
-  // This runs at the parent level to handle all items regardless of virtualization
-  const expandedForTargetPathRef = useRef<string | null>(null);
-  useEffect(() => {
-    // Skip if no items loaded yet
-    if (items.length === 0) {
-      return;
-    }
-
-    // Skip if already fully processed for this targetPath
-    if (expandedForTargetPathRef.current === targetPath) {
-      return;
-    }
-
-    let didExpand = false;
-
-    for (const item of items) {
-      const itemData = item.getItemData();
-      const itemPath = itemData.path;
-
-      if (itemPath == null) continue;
-
-      // Check if this item is an ancestor of targetPath
-      const isAncestorOfTarget =
-        itemPath === '/' ||
-        (targetPath.startsWith(addTrailingSlash(itemPath)) &&
-          targetPath !== itemPath);
-
-      if (!isAncestorOfTarget) continue;
-
-      const isFolder = item.isFolder();
-      const isExpanded = item.isExpanded();
-
-      if (isFolder && !isExpanded) {
-        item.expand();
-        didExpand = true;
-      }
-    }
-
-    // If we expanded any items, trigger re-render to load children
-    if (didExpand) {
-      setRebuildTrigger((prev) => prev + 1);
-    }
-    else {
-      // Only mark as fully processed when all ancestors are expanded
-      // Check if we have all the ancestors we need
-      const targetSegments = targetPath.split('/').filter(Boolean);
-      let hasAllAncestors = true;
-
-      // Build ancestor paths and check if they exist in items
-      for (let i = 0; i < targetSegments.length - 1; i++) {
-        const ancestorPath = '/' + targetSegments.slice(0, i + 1).join('/');
-        const ancestorItem = items.find(item => item.getItemData().path === ancestorPath);
-        if (!ancestorItem) {
-          hasAllAncestors = false;
-          break;
-        }
-      }
-
-      if (hasAllAncestors) {
-        expandedForTargetPathRef.current = targetPath;
-      }
-    }
-  }, [items, targetPath]);
+  const handleAutoExpanded = useCallback(() => {
+    setRebuildTrigger((prev) => prev + 1);
+  }, []);
+  useAutoExpandAncestors({
+    items,
+    targetPath,
+    onExpanded: handleAutoExpanded,
+  });
 
   const virtualizer = useVirtualizer({
     count: items.length,

+ 294 - 0
apps/app/src/features/page-tree/client/hooks/use-auto-expand-ancestors.spec.tsx

@@ -0,0 +1,294 @@
+import type { ItemInstance } from '@headless-tree/core';
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  getAncestorPaths,
+  isAncestorOf,
+  useAutoExpandAncestors,
+} from './use-auto-expand-ancestors';
+
+/**
+ * Create a mock item instance for testing
+ */
+const createMockItem = (
+  path: string,
+  options: {
+    isFolder?: boolean;
+    isExpanded?: boolean;
+  } = {},
+): ItemInstance<IPageForTreeItem> => {
+  const { isFolder = true, isExpanded = false } = options;
+  let expanded = isExpanded;
+
+  return {
+    getItemData: () => ({ path }) as IPageForTreeItem,
+    isFolder: () => isFolder,
+    isExpanded: () => expanded,
+    expand: vi.fn(() => {
+      expanded = true;
+    }),
+  } as unknown as ItemInstance<IPageForTreeItem>;
+};
+
+describe('use-auto-expand-ancestors', () => {
+  describe('getAncestorPaths', () => {
+    describe.each`
+      targetPath                      | expected
+      ${'/'}                          | ${[]}
+      ${'/Sandbox'}                   | ${[]}
+      ${'/Sandbox/Diagrams'}          | ${['/Sandbox']}
+      ${'/Sandbox/Diagrams/figure-1'} | ${['/Sandbox', '/Sandbox/Diagrams']}
+      ${'/a/b/c/d'}                   | ${['/a', '/a/b', '/a/b/c']}
+    `('should return $expected', ({ targetPath, expected }) => {
+      test(`when targetPath is '${targetPath}'`, () => {
+        const result = getAncestorPaths(targetPath);
+        expect(result).toEqual(expected);
+      });
+    });
+  });
+
+  describe('isAncestorOf', () => {
+    describe('when itemPath is root "/"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${true}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/SandboxOther'}              | ${false}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox/Diagrams"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/Sandbox/Diagrams'}          | ${false}
+        ${'/Sandbox/DiagramsOther'}     | ${false}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+        ${'/Sandbox/Diagrams/a/b/c'}    | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox/Diagrams', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+  });
+
+  describe('useAutoExpandAncestors', () => {
+    describe('when items is empty', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should call onExpanded when items become available on rerender', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/');
+
+        // First render with empty items
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [] as ItemInstance<IPageForTreeItem>[] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with items
+        rerender({ items: [rootItem] });
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when items contains ancestors that need to be expanded', () => {
+      test('should expand ancestor items and call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+
+      test('should not expand already expanded items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when not all ancestors are loaded yet', () => {
+      test('should continue expanding as ancestors become available', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+
+        // First render - only root is available
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem] } },
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox becomes available
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem] });
+
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox/Diagrams becomes available
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: false,
+        });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(diagramsItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when all ancestors are already expanded', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem, diagramsItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+        expect(diagramsItem.expand).not.toHaveBeenCalled();
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should not process again on rerender with same targetPath', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem, sandboxItem, diagramsItem] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with same props - should not process again
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when item is not a folder', () => {
+      test('should not expand non-folder items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', {
+          isFolder: false,
+          isExpanded: false,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 107 - 0
apps/app/src/features/page-tree/client/hooks/use-auto-expand-ancestors.ts

@@ -0,0 +1,107 @@
+import { useEffect, useRef } from 'react';
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+import type { ItemInstance } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type UseAutoExpandAncestorsProps = {
+  items: ItemInstance<IPageForTreeItem>[];
+  targetPath: string;
+  onExpanded?: () => void;
+};
+
+/**
+ * Get all ancestor paths for a given target path
+ * e.g., "/Sandbox/Diagrams/print-test" => ["/Sandbox", "/Sandbox/Diagrams"]
+ */
+export const getAncestorPaths = (targetPath: string): string[] => {
+  const segments = targetPath.split('/').filter(Boolean);
+  const ancestors: string[] = [];
+
+  // Build ancestor paths (excluding the target itself)
+  for (let i = 0; i < segments.length - 1; i++) {
+    ancestors.push('/' + segments.slice(0, i + 1).join('/'));
+  }
+
+  return ancestors;
+};
+
+/**
+ * Check if itemPath is an ancestor of targetPath
+ */
+export const isAncestorOf = (itemPath: string, targetPath: string): boolean => {
+  if (itemPath === '/') {
+    return targetPath !== '/';
+  }
+  return (
+    targetPath.startsWith(addTrailingSlash(itemPath)) && targetPath !== itemPath
+  );
+};
+
+/**
+ * Hook to auto-expand tree items that are ancestors of the target path.
+ * This is useful for revealing a deeply nested page in a tree view.
+ *
+ * The hook handles async data loading by:
+ * 1. Expanding ancestors as they become available in items
+ * 2. Waiting for all ancestor paths to be loaded before marking as complete
+ * 3. Re-running when items change (e.g., after children are loaded)
+ */
+export const useAutoExpandAncestors = ({
+  items,
+  targetPath,
+  onExpanded,
+}: UseAutoExpandAncestorsProps): void => {
+  const processedTargetPathRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    // Skip if no items loaded yet
+    if (items.length === 0) {
+      return;
+    }
+
+    // Skip if already fully processed for this targetPath
+    if (processedTargetPathRef.current === targetPath) {
+      return;
+    }
+
+    let didExpand = false;
+
+    for (const item of items) {
+      const itemData = item.getItemData();
+      const itemPath = itemData.path;
+
+      if (itemPath == null) continue;
+
+      // Check if this item is an ancestor of targetPath (including root "/")
+      const isAncestorOfTarget =
+        itemPath === '/' || isAncestorOf(itemPath, targetPath);
+
+      if (!isAncestorOfTarget) continue;
+
+      const isFolder = item.isFolder();
+      const isExpanded = item.isExpanded();
+
+      if (isFolder && !isExpanded) {
+        item.expand();
+        didExpand = true;
+      }
+    }
+
+    // If we expanded any items, trigger callback to re-render and load children
+    if (didExpand) {
+      onExpanded?.();
+    } else {
+      // Only mark as fully processed when all ancestors are expanded
+      // Check if we have all the ancestors we need
+      const ancestorPaths = getAncestorPaths(targetPath);
+      const hasAllAncestors = ancestorPaths.every((ancestorPath) =>
+        items.some((item) => item.getItemData().path === ancestorPath),
+      );
+
+      if (hasAllAncestors) {
+        processedTargetPathRef.current = targetPath;
+      }
+    }
+  }, [items, targetPath, onExpanded]);
+};