This is an automated email from the ASF dual-hosted git repository.
kgabryje 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 5e890a8cf76 fix(folders): remove stale column/metric refs from folders
on delete (#38302)
5e890a8cf76 is described below
commit 5e890a8cf7663c84568e6de750490122e6c2021a
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Fri Feb 27 17:25:06 2026 +0100
fix(folders): remove stale column/metric refs from folders on delete
(#38302)
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../components/Datasource/FoldersEditor/index.tsx | 19 ++++-
.../Datasource/FoldersEditor/treeUtils.test.ts | 84 ++++++++++++++++++++++
.../Datasource/FoldersEditor/treeUtils.ts | 46 ++++++++++++
.../DatasourceEditor/DatasourceEditor.tsx | 25 ++++++-
4 files changed, 170 insertions(+), 4 deletions(-)
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<string>();
+ 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<Set<string>>(
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<string>();
+ 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<string>,
+): 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<string>();
+ 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);