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 660357c76b0 feat: Persist default folders location when repositioned
in folders editor (#38105)
660357c76b0 is described below
commit 660357c76b032fb6271453d9671d36d3d36bd991
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Thu Feb 26 15:58:25 2026 +0100
feat: Persist default folders location when repositioned in folders editor
(#38105)
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../FoldersEditor/folderOperations.test.ts | 106 +++++++++++++++++++++
.../Datasource/FoldersEditor/folderOperations.ts | 57 +++++++----
.../DatasourceEditor/DatasourceEditor.tsx | 28 +++---
.../transformDatasourceFolders.test.ts | 12 ++-
.../DatasourcePanel/transformDatasourceFolders.ts | 71 +++++++++-----
superset/commands/dataset/update.py | 22 ++++-
tests/unit_tests/commands/dataset/update_test.py | 35 ++++++-
7 files changed, 271 insertions(+), 60 deletions(-)
diff --git
a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
index 0b0c8f7dd6b..79531acdaf0 100644
---
a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
+++
b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
@@ -250,5 +250,111 @@ describe('folderUtils', () => {
expect(result.length).toBeGreaterThan(2);
expect(result.find(f => f.name === 'Custom Folder')).toBeDefined();
});
+
+ test('should preserve default folder positions when saved', () => {
+ const customFolder = createFolder('Custom');
+ customFolder.children = [
+ {
+ uuid: 'metric-1',
+ type: FoldersEditorItemType.Metric,
+ name: 'Test Metric 1',
+ },
+ ];
+ const savedFolders = [
+ {
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder as const,
+ name: 'Metrics',
+ children: [
+ {
+ uuid: 'metric-2',
+ name: 'metric-2',
+ type: FoldersEditorItemType.Metric,
+ },
+ ],
+ },
+ customFolder,
+ {
+ uuid: DEFAULT_COLUMNS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder as const,
+ name: 'Columns',
+ children: [
+ {
+ uuid: 'column-1',
+ name: 'column-1',
+ type: FoldersEditorItemType.Column,
+ },
+ {
+ uuid: 'column-2',
+ name: 'column-2',
+ type: FoldersEditorItemType.Column,
+ },
+ ],
+ },
+ ];
+ const result = ensureDefaultFolders(
+ savedFolders,
+ mockMetrics,
+ mockColumns,
+ );
+
+ expect(result).toHaveLength(3);
+ expect(result[0].uuid).toBe(DEFAULT_METRICS_FOLDER_UUID);
+ expect(result[1].uuid).toBe(customFolder.uuid);
+ expect(result[2].uuid).toBe(DEFAULT_COLUMNS_FOLDER_UUID);
+ });
+
+ test('should add unassigned items to existing default folders', () => {
+ const savedFolders = [
+ {
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder as const,
+ name: 'Metrics',
+ children: [
+ {
+ uuid: 'metric-1',
+ name: 'metric-1',
+ type: FoldersEditorItemType.Metric,
+ },
+ ],
+ },
+ {
+ uuid: DEFAULT_COLUMNS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder as const,
+ name: 'Columns',
+ children: [
+ {
+ uuid: 'column-1',
+ name: 'column-1',
+ type: FoldersEditorItemType.Column,
+ },
+ ],
+ },
+ ];
+ const result = ensureDefaultFolders(
+ savedFolders,
+ mockMetrics,
+ mockColumns,
+ );
+
+ const metricsFolder = result.find(
+ f => f.uuid === DEFAULT_METRICS_FOLDER_UUID,
+ );
+ const columnsFolder = result.find(
+ f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID,
+ );
+
+ // metric-2 was unassigned, should be appended
+ expect(metricsFolder?.children).toHaveLength(2);
+ expect(metricsFolder?.children?.some(c => c.uuid === 'metric-2')).toBe(
+ true,
+ );
+
+ // column-2 was unassigned, should be appended
+ expect(columnsFolder?.children).toHaveLength(2);
+ expect(columnsFolder?.children?.some(c => c.uuid === 'column-2')).toBe(
+ true,
+ );
+ });
});
});
diff --git
a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
index e9f0b0d9119..cb810bd4a01 100644
---
a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
+++
b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
@@ -174,8 +174,6 @@ export const ensureDefaultFolders = (
f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID,
);
- const result = [...enrichedFolders];
-
// Build a Set of all assigned UUIDs in a single pass for O(1) lookups
const assignedIds = new Set<string>();
const collectAssignedIds = (folder: DatasourceFolder) => {
@@ -189,33 +187,60 @@ export const ensureDefaultFolders = (
};
enrichedFolders.forEach(collectAssignedIds);
- if (!hasMetricsFolder) {
- const unassignedMetrics = metrics.filter(m => !assignedIds.has(m.uuid));
+ const unassignedMetrics = metrics
+ .filter(m => !assignedIds.has(m.uuid))
+ .map(m => ({
+ type: FoldersEditorItemType.Metric as const,
+ uuid: m.uuid,
+ name: m.metric_name || '',
+ }));
+ const unassignedColumns = columns
+ .filter(c => !assignedIds.has(c.uuid))
+ .map(c => ({
+ type: FoldersEditorItemType.Column as const,
+ uuid: c.uuid,
+ name: c.column_name || '',
+ }));
+
+ // Add unassigned items to existing default folders (handles new items added
after last save)
+ const result = enrichedFolders.map(folder => {
+ if (
+ folder.uuid === DEFAULT_METRICS_FOLDER_UUID &&
+ unassignedMetrics.length > 0
+ ) {
+ return {
+ ...folder,
+ children: [...(folder.children || []), ...unassignedMetrics],
+ };
+ }
+ if (
+ folder.uuid === DEFAULT_COLUMNS_FOLDER_UUID &&
+ unassignedColumns.length > 0
+ ) {
+ return {
+ ...folder,
+ children: [...(folder.children || []), ...unassignedColumns],
+ };
+ }
+ return folder;
+ });
+ // If default folders don't exist at all, add them at the end (backward
compatibility)
+ if (!hasMetricsFolder) {
result.push({
uuid: DEFAULT_METRICS_FOLDER_UUID,
type: FoldersEditorItemType.Folder,
name: t('Metrics'),
- children: unassignedMetrics.map(m => ({
- type: FoldersEditorItemType.Metric,
- uuid: m.uuid,
- name: m.metric_name || '',
- })),
+ children: unassignedMetrics,
});
}
if (!hasColumnsFolder) {
- const unassignedColumns = columns.filter(c => !assignedIds.has(c.uuid));
-
result.push({
uuid: DEFAULT_COLUMNS_FOLDER_UUID,
type: FoldersEditorItemType.Folder,
name: t('Columns'),
- children: unassignedColumns.map(c => ({
- type: FoldersEditorItemType.Column,
- uuid: c.uuid,
- name: c.column_name || '',
- })),
+ children: unassignedColumns,
});
}
diff --git
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index fe1a35cc72e..fd3b899f399 100644
---
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -91,9 +91,8 @@ import Field from '../Field';
import { fetchSyncedColumns, updateColumns } from '../../utils';
import DatasetUsageTab from './components/DatasetUsageTab';
import {
- DEFAULT_COLUMNS_FOLDER_UUID,
DEFAULT_FOLDERS_COUNT,
- DEFAULT_METRICS_FOLDER_UUID,
+ isDefaultFolder,
} from '../../FoldersEditor/constants';
import { validateFolders } from '../../FoldersEditor/folderValidation';
import { countAllFolders } from '../../FoldersEditor/treeUtils';
@@ -942,8 +941,14 @@ class DatasourceEditor extends PureComponent<
col => !!col.expression,
),
folders: props.datasource.folders || [],
- folderCount:
- countAllFolders(props.datasource.folders || []) +
DEFAULT_FOLDERS_COUNT,
+ folderCount: (() => {
+ const savedFolders = props.datasource.folders || [];
+ const savedCount = countAllFolders(savedFolders);
+ const hasDefaultsSaved = savedFolders.some(f =>
+ isDefaultFolder(f.uuid),
+ );
+ return savedCount + (hasDefaultsSaved ? 0 : DEFAULT_FOLDERS_COUNT);
+ })(),
metadataLoading: false,
activeTabKey: TABS_KEYS.SOURCE,
datasourceType: props.datasource.sql
@@ -1031,16 +1036,10 @@ class DatasourceEditor extends PureComponent<
handleFoldersChange(folders: DatasourceFolder[]) {
const folderCount = countAllFolders(folders);
- const userMadeFolders = folders.filter(
- f =>
- f.uuid !== DEFAULT_METRICS_FOLDER_UUID &&
- f.uuid !== DEFAULT_COLUMNS_FOLDER_UUID &&
- (f.children?.length ?? 0) > 0,
- );
- this.setState({ folders: userMadeFolders, folderCount }, () => {
+ this.setState({ folders, folderCount }, () => {
this.onDatasourceChange({
...this.state.datasource,
- folders: userMadeFolders,
+ folders,
});
});
}
@@ -2493,7 +2492,10 @@ class DatasourceEditor extends PureComponent<
metrics={
sortedMetrics as unknown as
import('@superset-ui/chart-controls').Metric[]
}
- columns={this.state.databaseColumns}
+ columns={[
+ ...this.state.databaseColumns,
+ ...this.state.calculatedColumns,
+ ]}
onChange={this.handleFoldersChange}
/>
),
diff --git
a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts
b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts
index 33f922c69f6..c2bd343649b 100644
---
a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts
+++
b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts
@@ -21,6 +21,10 @@ import { Metric } from '@superset-ui/core';
import { transformDatasourceWithFolders } from './transformDatasourceFolders';
import { DatasourceFolder, DatasourcePanelColumn } from './types';
import { FoldersEditorItemType } from 'src/components/Datasource/types';
+import {
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+} from 'src/components/Datasource/FoldersEditor/constants';
const mockMetrics: Metric[] = [
{ metric_name: 'metric1', uuid: 'metric1-uuid', expression: 'SUM(col1)' },
@@ -45,13 +49,13 @@ test('transforms data into default folders when no folder
config is provided', (
expect(result).toHaveLength(2);
- expect(result[0].id).toBe('metrics-default');
+ expect(result[0].id).toBe(DEFAULT_METRICS_FOLDER_UUID);
expect(result[0].name).toBe('Metrics');
expect(result[0].items).toHaveLength(3);
expect(result[0].items[0].uuid).toBe('metric1-uuid');
expect(result[0].items[0].type).toBe(FoldersEditorItemType.Metric);
- expect(result[1].id).toBe('columns-default');
+ expect(result[1].id).toBe(DEFAULT_COLUMNS_FOLDER_UUID);
expect(result[1].name).toBe('Columns');
expect(result[1].items).toHaveLength(3);
expect(result[1].items[0].uuid).toBe('column1-uuid');
@@ -118,11 +122,11 @@ test('transforms data according to folder configuration',
() => {
expect(result[1].items).toHaveLength(1);
expect(result[1].items[0].uuid).toBe('column1-uuid');
- expect(result[2].id).toBe('metrics-default');
+ expect(result[2].id).toBe(DEFAULT_METRICS_FOLDER_UUID);
expect(result[2].items).toHaveLength(1);
expect(result[2].items[0].uuid).toBe('metric3-uuid');
- expect(result[3].id).toBe('columns-default');
+ expect(result[3].id).toBe(DEFAULT_COLUMNS_FOLDER_UUID);
expect(result[3].items).toHaveLength(2);
});
diff --git
a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts
b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts
index 414b7f85a62..eb94823069a 100644
---
a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts
+++
b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts
@@ -19,6 +19,10 @@
import { t } from '@apache-superset/core';
import { Metric } from '@superset-ui/core';
import { FoldersEditorItemType } from 'src/components/Datasource/types';
+import {
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+} from 'src/components/Datasource/FoldersEditor/constants';
import {
ColumnItem,
DatasourceFolder,
@@ -98,10 +102,36 @@ const transformToFolderStructure = (
return folder;
};
+ const addUnassignedToFolder = (
+ folders: Folder[],
+ items: (MetricItem | ColumnItem)[],
+ folderId: string,
+ folderName: string,
+ allItemsCount: number,
+ inFoldersCount: number,
+ ) => {
+ if (items.length === 0) return;
+ const existing = folders.find(f => f.id === folderId);
+ if (existing) {
+ existing.items.push(...items);
+ existing.totalItems += items.length;
+ existing.showingItems += items.length;
+ } else {
+ folders.push({
+ id: folderId,
+ name: folderName,
+ isCollapsed: false,
+ items,
+ totalItems: allItemsCount - inFoldersCount,
+ showingItems: items.length,
+ });
+ }
+ };
+
if (!folderConfig) {
return [
{
- id: 'metrics-default',
+ id: DEFAULT_METRICS_FOLDER_UUID,
name: t('Metrics'),
isCollapsed: false,
items: metricsToDisplay,
@@ -109,7 +139,7 @@ const transformToFolderStructure = (
showingItems: metricsToDisplay.length,
},
{
- id: 'columns-default',
+ id: DEFAULT_COLUMNS_FOLDER_UUID,
name: t('Columns'),
isCollapsed: false,
items: columnsToDisplay,
@@ -128,27 +158,22 @@ const transformToFolderStructure = (
columnsMap.has(column.uuid),
);
- if (unassignedMetrics.length > 0) {
- folders.push({
- id: 'metrics-default',
- name: t('Metrics'),
- isCollapsed: false,
- items: unassignedMetrics,
- totalItems: allMetrics.length - metricsInFolders,
- showingItems: unassignedMetrics.length,
- });
- }
-
- if (unassignedColumns.length > 0) {
- folders.push({
- id: 'columns-default',
- name: t('Columns'),
- isCollapsed: false,
- items: unassignedColumns,
- totalItems: allColumns.length - columnsInFolders,
- showingItems: unassignedColumns.length,
- });
- }
+ addUnassignedToFolder(
+ folders,
+ unassignedMetrics,
+ DEFAULT_METRICS_FOLDER_UUID,
+ t('Metrics'),
+ allMetrics.length,
+ metricsInFolders,
+ );
+ addUnassignedToFolder(
+ folders,
+ unassignedColumns,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+ t('Columns'),
+ allColumns.length,
+ columnsInFolders,
+ );
return folders;
};
diff --git a/superset/commands/dataset/update.py
b/superset/commands/dataset/update.py
index be86cd876b4..3acab024b9e 100644
--- a/superset/commands/dataset/update.py
+++ b/superset/commands/dataset/update.py
@@ -54,6 +54,14 @@ from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
+# Default folder UUIDs matching the frontend constants.
+# Stored as strings so comparisons work whether obj["uuid"] is str or UUID.
+DEFAULT_METRICS_FOLDER_UUID = "255b537d-58c8-443d-9fc1-4e4dc75047e2"
+DEFAULT_COLUMNS_FOLDER_UUID = "83a7ae8f-2e8a-4f2b-a8cb-ebaebef95b9b"
+DEFAULT_FOLDER_UUIDS = frozenset(
+ {DEFAULT_METRICS_FOLDER_UUID, DEFAULT_COLUMNS_FOLDER_UUID}
+)
+
class UpdateDatasetCommand(UpdateMixin, BaseCommand):
def __init__(
@@ -320,11 +328,19 @@ def validate_folders( # noqa: C901
raise ValidationError(f"Duplicate folder name: {name}")
seen_fqns.add(fqn)
- if name.lower() in {"metrics", "columns"}:
+ # Allow default folders (by UUID) to use reserved names
+ if (
+ name.lower() in {"metrics", "columns"}
+ and str(uuid) not in DEFAULT_FOLDER_UUIDS
+ ):
raise ValidationError(f"Folder cannot have name '{name}'")
- # check if metric/column UUID exists
- elif not name and uuid not in valid_uuids:
+ # check if metric/column UUID exists (skip default folders)
+ elif (
+ not name
+ and uuid not in valid_uuids
+ and str(uuid) not in DEFAULT_FOLDER_UUIDS
+ ):
raise ValidationError(f"Invalid UUID: {uuid}")
# traverse children
diff --git a/tests/unit_tests/commands/dataset/update_test.py
b/tests/unit_tests/commands/dataset/update_test.py
index 5eb26fea4a5..afc33741c51 100644
--- a/tests/unit_tests/commands/dataset/update_test.py
+++ b/tests/unit_tests/commands/dataset/update_test.py
@@ -29,7 +29,12 @@ from superset.commands.dataset.exceptions import (
DatasetNotFoundError,
MultiCatalogDisabledValidationError,
)
-from superset.commands.dataset.update import UpdateDatasetCommand,
validate_folders
+from superset.commands.dataset.update import (
+ DEFAULT_COLUMNS_FOLDER_UUID,
+ DEFAULT_METRICS_FOLDER_UUID,
+ UpdateDatasetCommand,
+ validate_folders,
+)
from superset.commands.exceptions import OwnersNotFoundValidationError
from superset.datasets.schemas import FolderSchema
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -564,6 +569,34 @@ def test_validate_folders_invalid_names(mocker:
MockerFixture) -> None:
assert str(excinfo.value) == "Folder cannot have name 'Columns'"
+@with_feature_flags(DATASET_FOLDERS=True)
+def test_validate_folders_allows_default_folders(mocker: MockerFixture) ->
None:
+ """
+ Test that default system folders (Metrics/Columns) are allowed when using
+ the well-known default folder UUIDs, so their position can be persisted.
+ """
+ folders = cast(
+ list[FolderSchema],
+ [
+ {
+ "uuid": DEFAULT_METRICS_FOLDER_UUID,
+ "type": "folder",
+ "name": "Metrics",
+ "children": [],
+ },
+ {
+ "uuid": DEFAULT_COLUMNS_FOLDER_UUID,
+ "type": "folder",
+ "name": "Columns",
+ "children": [],
+ },
+ ],
+ )
+
+ # Should not raise - default folders are allowed to use reserved names
+ validate_folders(folders=folders, valid_uuids=set())
+
+
@with_feature_flags(DATASET_FOLDERS=True)
def test_validate_folders_invalid_uuid(mocker: MockerFixture) -> None:
"""