This is an automated email from the ASF dual-hosted git repository.
jli pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 7f280f5de9 fix(Dataset Folders): improve search-collapse (#38188)
7f280f5de9 is described below
commit 7f280f5de901e58eaf800b456b96509c50fb7cbf
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Sat Feb 28 08:21:29 2026 +0300
fix(Dataset Folders): improve search-collapse (#38188)
---
.../FoldersEditor/FoldersEditor.test.tsx | 272 +++++++++++++++++++++
.../components/Datasource/FoldersEditor/index.tsx | 193 +++++++++++++--
2 files changed, 441 insertions(+), 24 deletions(-)
diff --git
a/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx
b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx
index 4808f03a6d..a8b9a0c5cb 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(<FoldersEditor {...testProps} />);
+
+ // 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(<FoldersEditor {...testProps} />);
+
+ // 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(<FoldersEditor {...testProps} />);
+
+ // 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(<FoldersEditor {...testProps} />);
+
+ // 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(<FoldersEditor {...testProps} />);
+
+ // 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 d10c5f9a3f..e48db97397 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<Set<string>>(new Set());
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
+ const [preSearchCollapsedIds, setPreSearchCollapsedIds] =
+ useState<Set<string> | 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<string>();
+ metrics.forEach(m => allIds.add(m.uuid));
+ columns.forEach(c => allIds.add(c.uuid));
+ return {
+ visibleItemIds: allIds,
+ searchExpandedFolderIds: new Set<string>(),
+ foldersWithMatches: new Set<string>(),
+ };
+ }
+
+ const allItems = [...metrics, ...columns];
+ const matchingItemIds = filterItemsBySearch(searchTerm, allItems);
+ const expandedFolders = new Set<string>();
+ const matchingFolders = new Set<string>();
+ 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<string>,
+ ) => {
+ 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<string>(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<string>();
- 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<HTMLInputElement>) => {