mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
fix(Dataset Folders): improve search-collapse (#38188)
This commit is contained in:
committed by
GitHub
parent
d039172013
commit
7f280f5de9
@@ -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[] = [
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
Reference in New Issue
Block a user