diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx index 4733dc91726..a7a775ecc3e 100644 --- a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx +++ b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { debounce } from 'lodash'; import AutoSizer from 'react-virtualized-auto-sizer'; import { @@ -32,6 +32,7 @@ import { SortableContext, verticalListSortingStrategy, } from '@dnd-kit/sortable'; +import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; import { FoldersEditorItemType } from '../types'; import { TreeItem as TreeItemType } from './constants'; import { @@ -39,6 +40,7 @@ import { buildTree, removeChildrenOf, serializeForAPI, + filterFoldersByValidUuids, } from './treeUtils'; import { createFolder, @@ -80,6 +82,21 @@ export default function FoldersEditor({ return ensured; }); + // Sync folders when columns/metrics are removed externally + useEffect(() => { + const validUuids = new Set(); + columns.forEach(c => { + if (c.uuid) validUuids.add(c.uuid); + }); + metrics.forEach(m => { + if (m.uuid) validUuids.add(m.uuid); + }); + + setItems(prevItems => + filterFoldersByValidUuids(prevItems as DatasourceFolder[], validUuids), + ); + }, [columns, metrics]); + const [selectedItemIds, setSelectedItemIds] = useState>( new Set(), ); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts index b17b7b727fe..d1b87552b85 100644 --- a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts +++ b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts @@ -29,6 +29,7 @@ import { serializeForAPI, getProjection, countAllFolders, + filterFoldersByValidUuids, } from './treeUtils'; import { FoldersEditorItemType } from '../types'; import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; @@ -726,3 +727,86 @@ test('countAllFolders ignores non-folder children', () => { expect(countAllFolders(folders)).toBe(1); }); + +test('filterFoldersByValidUuids removes items with invalid UUIDs', () => { + const folders: DatasourceFolder[] = [ + createFolderItem('f1', 'Metrics', [ + createMetricItem('m1', 'Metric 1'), + createMetricItem('m2', 'Metric 2'), + ]), + ] as DatasourceFolder[]; + + const validUuids = new Set(['m1']); + const filtered = filterFoldersByValidUuids(folders, validUuids); + + expect(filtered).toHaveLength(1); + expect(filtered[0].children).toHaveLength(1); + expect(filtered[0].children![0].uuid).toBe('m1'); +}); + +test('filterFoldersByValidUuids preserves folders even when empty', () => { + const folders: DatasourceFolder[] = [ + createFolderItem('f1', 'Metrics', [createMetricItem('m1', 'Metric 1')]), + ] as DatasourceFolder[]; + + const validUuids = new Set(); + const filtered = filterFoldersByValidUuids(folders, validUuids); + + expect(filtered).toHaveLength(1); + expect(filtered[0].uuid).toBe('f1'); + expect(filtered[0].children).toHaveLength(0); +}); + +test('filterFoldersByValidUuids handles nested folders', () => { + const folders: DatasourceFolder[] = [ + createFolderItem('f1', 'Root', [ + createFolderItem('f2', 'Nested', [ + createMetricItem('m1', 'Metric 1'), + createColumnItem('c1', 'Column 1'), + ]), + createColumnItem('c2', 'Column 2'), + ]), + ] as DatasourceFolder[]; + + const validUuids = new Set(['m1', 'c2']); + const filtered = filterFoldersByValidUuids(folders, validUuids); + + expect(filtered).toHaveLength(1); + expect(filtered[0].children).toHaveLength(2); + + const nestedFolder = filtered[0].children![0] as DatasourceFolder; + expect(nestedFolder.uuid).toBe('f2'); + expect(nestedFolder.children).toHaveLength(1); + expect(nestedFolder.children![0].uuid).toBe('m1'); + + expect(filtered[0].children![1].uuid).toBe('c2'); +}); + +test('filterFoldersByValidUuids keeps all items when all UUIDs are valid', () => { + const folders: DatasourceFolder[] = [ + createFolderItem('f1', 'Metrics', [ + createMetricItem('m1', 'Metric 1'), + createMetricItem('m2', 'Metric 2'), + ]), + ] as DatasourceFolder[]; + + const validUuids = new Set(['m1', 'm2']); + const filtered = filterFoldersByValidUuids(folders, validUuids); + + expect(filtered).toHaveLength(1); + expect(filtered[0].children).toHaveLength(2); +}); + +test('filterFoldersByValidUuids returns same reference when nothing changed', () => { + const folders: DatasourceFolder[] = [ + createFolderItem('f1', 'Root', [ + createFolderItem('f2', 'Nested', [createMetricItem('m1', 'Metric 1')]), + createColumnItem('c1', 'Column 1'), + ]), + ] as DatasourceFolder[]; + + const validUuids = new Set(['m1', 'c1']); + const filtered = filterFoldersByValidUuids(folders, validUuids); + + expect(filtered).toBe(folders); +}); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts index c35817606d9..fbf89dbb6d3 100644 --- a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts +++ b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts @@ -331,6 +331,52 @@ export function serializeForAPI(items: TreeItem[]): DatasourceFolder[] { .filter((folder): folder is DatasourceFolder => folder !== null); } +/** + * Remove leaf items whose UUIDs are not in the valid set. + * Returns the original reference when nothing was removed. + */ +export function filterFoldersByValidUuids( + folders: DatasourceFolder[], + validUuids: Set, +): DatasourceFolder[] { + const filterChildren = ( + children: (DatasourceFolder | DatasourceFolderItem)[] | undefined, + ): (DatasourceFolder | DatasourceFolderItem)[] | undefined => { + if (!children) return children; + + let childChanged = false; + const result: (DatasourceFolder | DatasourceFolderItem)[] = []; + for (const child of children) { + if (child.type === FoldersEditorItemType.Folder && 'children' in child) { + const filtered = filterChildren((child as DatasourceFolder).children); + if (filtered !== (child as DatasourceFolder).children) { + childChanged = true; + result.push({ ...child, children: filtered } as DatasourceFolder); + } else { + result.push(child); + } + } else if (validUuids.has(child.uuid)) { + result.push(child); + } else { + childChanged = true; + } + } + return childChanged ? result : children; + }; + + let changed = false; + const result = folders.map(folder => { + const filtered = filterChildren(folder.children); + if (filtered !== folder.children) { + changed = true; + return { ...folder, children: filtered }; + } + return folder; + }); + + return changed ? result : folders; +} + /** * Recursively counts all folders in a DatasourceFolder array, * including nested sub-folders within children. diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx index fd3b899f399..5e8be8d450b 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx @@ -95,7 +95,10 @@ import { isDefaultFolder, } from '../../FoldersEditor/constants'; import { validateFolders } from '../../FoldersEditor/folderValidation'; -import { countAllFolders } from '../../FoldersEditor/treeUtils'; +import { + countAllFolders, + filterFoldersByValidUuids, +} from '../../FoldersEditor/treeUtils'; import FoldersEditor from '../../FoldersEditor'; import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; @@ -135,6 +138,7 @@ interface Metric { interface Column { id?: number; + uuid?: string; column_name: string; verbose_name?: string; description?: string; @@ -992,11 +996,26 @@ class DatasourceEditor extends PureComponent< const sql = datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql; + const columns = [ + ...this.state.databaseColumns, + ...this.state.calculatedColumns, + ]; + + // Remove deleted column/metric references from folders + const validUuids = new Set(); + for (const col of columns) { + if (col.uuid) validUuids.add(col.uuid); + } + for (const metric of datasource.metrics ?? []) { + if (metric.uuid) validUuids.add(metric.uuid); + } + const folders = filterFoldersByValidUuids(this.state.folders, validUuids); + const newDatasource = { ...this.state.datasource, sql, - columns: [...this.state.databaseColumns, ...this.state.calculatedColumns], - folders: this.state.folders, + columns, + folders, }; this.props.onChange?.(newDatasource, this.state.errors);