fix(Dataset Folders): improve search-collapse (#38188)

This commit is contained in:
Mehmet Salih Yavuz
2026-02-28 08:21:29 +03:00
committed by GitHub
parent d039172013
commit 7f280f5de9
2 changed files with 441 additions and 24 deletions

View File

@@ -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[] = [

View File

@@ -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>) => {