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);

Reply via email to