diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx index 4808f03a6dc..a8b9a0c5cb7 100644 --- a/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx +++ b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx @@ -560,6 +560,278 @@ test('select all expands collapsed folders', async () => { }); }); +test('auto-expands folders when searching for items inside them', async () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Count', + }, + { + uuid: 'metric2', + type: FoldersEditorItemType.Metric, + name: 'Sum Revenue', + }, + ], + }, + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + name: 'Columns', + children: [ + { + uuid: 'col1', + type: FoldersEditorItemType.Column, + name: 'id', + }, + ], + }, + ], + }; + + renderEditor(); + + // Collapse the Metrics folder first + const metricsIcon = screen.getAllByRole('img', { name: 'down' })[0]; + fireEvent.click(metricsIcon); + + await waitFor(() => { + expect(screen.queryByText('Count')).not.toBeInTheDocument(); + }); + + // Search for "Count" - folder should auto-expand + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Count'); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + }); +}); + +test('hides folders that do not contain matching items', async () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Count', + }, + ], + }, + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + name: 'Columns', + children: [ + { + uuid: 'col1', + type: FoldersEditorItemType.Column, + name: 'id', + }, + ], + }, + ], + }; + + renderEditor(); + + // Search for "Count" - only Metrics folder should be visible + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Count'); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + // Columns folder should be hidden since it has no matching items + expect(screen.queryByText('Columns')).not.toBeInTheDocument(); + }); +}); + +test('shows all children when folder name matches search', async () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Count', + }, + { + uuid: 'metric2', + type: FoldersEditorItemType.Metric, + name: 'Sum Revenue', + }, + ], + }, + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + name: 'Columns', + children: [ + { + uuid: 'col1', + type: FoldersEditorItemType.Column, + name: 'id', + }, + ], + }, + ], + }; + + renderEditor(); + + // Search for "Metrics" - all children in Metrics folder should be visible + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Metrics'); + + await waitFor(() => { + expect(screen.getByText('Metrics')).toBeInTheDocument(); + // All children should be visible even if they don't match + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('Sum Revenue')).toBeInTheDocument(); + // Columns folder should be hidden + expect(screen.queryByText('Columns')).not.toBeInTheDocument(); + }); +}); + +test('restores previous collapsed state when search is cleared', async () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Count', + }, + ], + }, + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + name: 'Columns', + children: [ + { + uuid: 'col1', + type: FoldersEditorItemType.Column, + name: 'id', + }, + ], + }, + ], + }; + + renderEditor(); + + // Collapse Metrics folder + const metricsIcon = screen.getAllByRole('img', { name: 'down' })[0]; + fireEvent.click(metricsIcon); + + await waitFor(() => { + expect(screen.queryByText('Count')).not.toBeInTheDocument(); + }); + + // Search for "Count" - folder auto-expands + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Count'); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + }); + + // Clear search - folder should be collapsed again + await userEvent.clear(searchInput); + + await waitFor(() => { + // Both folders should be visible + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + // But Metrics folder should be collapsed again as it was before search + expect(screen.queryByText('Count')).not.toBeInTheDocument(); + // Columns folder should still show its content (was expanded before search) + expect(screen.getByText('id')).toBeInTheDocument(); + }); +}); + +test('handles nested folders correctly during search', async () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: 'parent', + name: 'Parent', + children: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: 'nested', + name: 'Nested Folder', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Deep Metric', + }, + ], + } as DatasourceFolder, + ], + }, + ], + metrics: [ + { + uuid: 'metric1', + metric_name: 'Deep Metric', + expression: 'COUNT(*)', + }, + ], + }; + + renderEditor(); + + // Search for "Deep" - both parent and nested folder should expand + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Deep'); + + await waitFor(() => { + expect(screen.getByText('Parent')).toBeInTheDocument(); + expect(screen.getByText('Nested Folder')).toBeInTheDocument(); + expect(screen.getByText('Deep Metric')).toBeInTheDocument(); + }); +}); + test('nested folders with items remain visible after drag is cancelled', async () => { const onChange = jest.fn(); const nestedFolders: DatasourceFolder[] = [ diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx index d10c5f9a3f0..e48db973978 100644 --- a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx +++ b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx @@ -105,6 +105,8 @@ export default function FoldersEditor({ const [searchTerm, setSearchTerm] = useState(''); const [collapsedIds, setCollapsedIds] = useState>(new Set()); const [editingFolderId, setEditingFolderId] = useState(null); + const [preSearchCollapsedIds, setPreSearchCollapsedIds] = + useState | null>(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const sensors = useSensors(useSensor(PointerSensor, pointerSensorOptions)); @@ -113,42 +115,176 @@ export default function FoldersEditor({ const fullFlattenedItems = useMemo(() => flattenTree(items), [items]); + // Track which folders contain matching items during search + const { visibleItemIds, searchExpandedFolderIds, foldersWithMatches } = + useMemo(() => { + if (!searchTerm) { + const allIds = new Set(); + metrics.forEach(m => allIds.add(m.uuid)); + columns.forEach(c => allIds.add(c.uuid)); + return { + visibleItemIds: allIds, + searchExpandedFolderIds: new Set(), + foldersWithMatches: new Set(), + }; + } + + const allItems = [...metrics, ...columns]; + const matchingItemIds = filterItemsBySearch(searchTerm, allItems); + const expandedFolders = new Set(); + const matchingFolders = new Set(); + const lowerSearch = searchTerm.toLowerCase(); + + // Helper to check if folder title matches search + const folderMatches = (folder: TreeItemType): boolean => + folder.type === FoldersEditorItemType.Folder && + folder.name?.toLowerCase().includes(lowerSearch); + + // Helper to recursively check if a folder contains matching items + const folderContainsMatches = (folder: TreeItemType): boolean => { + if (folder.type !== FoldersEditorItemType.Folder) return false; + + // If folder name matches, it contains matches + if (folderMatches(folder)) { + return true; + } + + // Check direct children + if ( + folder.children?.some(child => { + if (child.type === FoldersEditorItemType.Folder) { + return folderContainsMatches(child); + } + return matchingItemIds.has(child.uuid); + }) + ) { + return true; + } + + return false; + }; + + // Helper to get all item IDs in a folder + const getAllItemsInFolder = ( + folder: TreeItemType, + itemSet: Set, + ) => { + if ('children' in folder && folder.children) { + folder.children.forEach((child: TreeItemType) => { + if (child.type === FoldersEditorItemType.Folder) { + getAllItemsInFolder(child, itemSet); + } else { + itemSet.add(child.uuid); + } + }); + } + }; + + // Process each folder to determine which should expand and which items to show + const finalVisibleItems = new Set(matchingItemIds); + + items.forEach(item => { + if (item.type === FoldersEditorItemType.Folder) { + if (folderMatches(item)) { + // Folder title matches - expand and show all children + expandedFolders.add(item.uuid); + matchingFolders.add(item.uuid); + getAllItemsInFolder(item, finalVisibleItems); + + // Recursively expand all subfolders + const expandAllSubfolders = (folder: TreeItemType) => { + if ('children' in folder && folder.children) { + folder.children.forEach((child: TreeItemType) => { + if (child.type === FoldersEditorItemType.Folder) { + expandedFolders.add(child.uuid); + matchingFolders.add(child.uuid); + expandAllSubfolders(child); + } + }); + } + }; + expandAllSubfolders(item); + } else if (folderContainsMatches(item)) { + // Folder contains matching items - expand it + expandedFolders.add(item.uuid); + matchingFolders.add(item.uuid); + + // Recursively expand subfolders that contain matches + const expandMatchingSubfolders = (folder: TreeItemType) => { + if ('children' in folder && folder.children) { + folder.children.forEach((child: TreeItemType) => { + if ( + child.type === FoldersEditorItemType.Folder && + folderContainsMatches(child) + ) { + expandedFolders.add(child.uuid); + matchingFolders.add(child.uuid); + expandMatchingSubfolders(child); + } + }); + } + }; + expandMatchingSubfolders(item); + } + } + }); + + return { + visibleItemIds: finalVisibleItems, + searchExpandedFolderIds: expandedFolders, + foldersWithMatches: matchingFolders, + }; + }, [searchTerm, metrics, columns, items]); + const collapsedFolderIds = useMemo(() => { const result: UniqueIdentifier[] = []; for (const { uuid, type, children } of fullFlattenedItems) { - if ( - type === FoldersEditorItemType.Folder && - collapsedIds.has(uuid) && - children?.length - ) { - result.push(uuid); + if (type === FoldersEditorItemType.Folder && children?.length) { + // During search, use search-expanded folders + if (searchTerm) { + if (!searchExpandedFolderIds.has(uuid)) { + result.push(uuid); + } + } else { + // Normal collapse state when not searching + if (collapsedIds.has(uuid)) { + result.push(uuid); + } + } } } return result; - }, [fullFlattenedItems, collapsedIds]); + }, [fullFlattenedItems, collapsedIds, searchTerm, searchExpandedFolderIds]); const computeFlattenedItems = useCallback( - (activeId: UniqueIdentifier | null) => - removeChildrenOf( - fullFlattenedItems, + (activeId: UniqueIdentifier | null) => { + // During search, filter out folders that don't match + let itemsToProcess = fullFlattenedItems; + if (searchTerm && foldersWithMatches) { + itemsToProcess = fullFlattenedItems.filter(item => { + if (item.type === FoldersEditorItemType.Folder) { + return foldersWithMatches.has(item.uuid); + } + return visibleItemIds.has(item.uuid); + }); + } + + return removeChildrenOf( + itemsToProcess, activeId != null ? [activeId, ...collapsedFolderIds] : collapsedFolderIds, - ), - [fullFlattenedItems, collapsedFolderIds], + ); + }, + [ + fullFlattenedItems, + collapsedFolderIds, + searchTerm, + foldersWithMatches, + visibleItemIds, + ], ); - const visibleItemIds = useMemo(() => { - if (!searchTerm) { - const allIds = new Set(); - metrics.forEach(m => allIds.add(m.uuid)); - columns.forEach(c => allIds.add(c.uuid)); - return allIds; - } - const allItems = [...metrics, ...columns]; - return filterItemsBySearch(searchTerm, allItems); - }, [searchTerm, metrics, columns]); - const metricsMap = useMemo( () => new Map(metrics.map(m => [m.uuid, m])), [metrics], @@ -184,9 +320,18 @@ export default function FoldersEditor({ const debouncedSearch = useCallback( debounce((term: string) => { + // Save collapsed state before search starts + if (!searchTerm && term) { + setPreSearchCollapsedIds(new Set(collapsedIds)); + } + // Restore collapsed state when search is cleared + if (searchTerm && !term && preSearchCollapsedIds) { + setCollapsedIds(preSearchCollapsedIds); + setPreSearchCollapsedIds(null); + } setSearchTerm(term); }, 300), - [], + [searchTerm, collapsedIds, preSearchCollapsedIds], ); const handleSearch = (e: React.ChangeEvent) => {