From a6a66ca483287783729fa80757395b4282d74987 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Mon, 2 Feb 2026 14:54:33 +0100 Subject: [PATCH] feat: Dataset folders editor (#36239) --- docker/pythonpath_dev/superset_config.py | 2 +- requirements/development.txt | 2 +- superset-frontend/jest.config.js | 2 +- superset-frontend/package-lock.json | 44 +- superset-frontend/package.json | 1 + .../ColumnTypeLabel/ColumnTypeLabel.tsx | 7 +- .../src/components/MetricOption.tsx | 2 +- .../test/components/ColumnTypeLabel.test.tsx | 4 + .../src/components/Icons/AntdEnhanced.tsx | 2 + .../src/components/Icons/index.tsx | 2 + .../src/utils/featureFlags.ts | 1 + .../src/assets/images/icons/move.svg | 33 + .../src/assets/images/icons/sigma.svg | 22 + .../Datasource/DatasourceModal/index.tsx | 10 +- .../FoldersEditor/FoldersEditor.test.tsx | 545 ++++++++++++++ .../FoldersEditor/TreeItem.styles.ts | 214 ++++++ .../Datasource/FoldersEditor/TreeItem.tsx | 396 +++++++++++ .../FoldersEditor/VirtualizedTreeItem.tsx | 220 ++++++ .../FoldersEditor/VirtualizedTreeList.tsx | 229 ++++++ .../components/DragOverlayContent.tsx | 75 ++ .../components/FoldersToolbarComponent.tsx | 78 ++ .../components/ResetConfirmModal.tsx | 51 ++ .../FoldersEditor/components/index.ts | 22 + .../Datasource/FoldersEditor/constants.ts | 60 ++ .../FoldersEditor/folderOperations.test.ts | 217 ++++++ .../FoldersEditor/folderOperations.ts | 217 ++++++ .../FoldersEditor/folderValidation.ts | 109 +++ .../FoldersEditor/hooks/useAutoScroll.ts | 191 +++++ .../FoldersEditor/hooks/useDragHandlers.ts | 663 +++++++++++++++++ .../FoldersEditor/hooks/useHeightCache.ts | 81 +++ .../FoldersEditor/hooks/useItemHeights.ts | 89 +++ .../Datasource/FoldersEditor/index.tsx | 467 ++++++++++++ .../Datasource/FoldersEditor/sensors.ts | 47 ++ .../Datasource/FoldersEditor/styles.tsx | 90 +++ .../FoldersEditor/treeUtils.test.ts | 669 ++++++++++++++++++ .../Datasource/FoldersEditor/treeUtils.ts | 332 +++++++++ .../Datasource/FoldersEditor/types.ts | 27 + .../DatasourceEditor/DatasourceEditor.jsx | 52 ++ .../src/components/Datasource/types.ts | 6 + .../DatasourcePanel/DatasourcePanel.test.tsx | 23 +- .../DatasourcePanelItem.test.tsx | 14 +- .../transformDatasourceFolders.test.ts | 61 +- .../transformDatasourceFolders.ts | 5 +- .../components/DatasourcePanel/types.ts | 18 +- .../MetricControl/FilterDefinitionOption.tsx | 4 +- superset/commands/dataset/update.py | 13 +- 46 files changed, 5354 insertions(+), 65 deletions(-) create mode 100644 superset-frontend/src/assets/images/icons/move.svg create mode 100644 superset-frontend/src/assets/images/icons/sigma.svg create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/constants.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/index.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts create mode 100644 superset-frontend/src/components/Datasource/FoldersEditor/types.ts diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py index d88d9899c27..108305cf900 100644 --- a/docker/pythonpath_dev/superset_config.py +++ b/docker/pythonpath_dev/superset_config.py @@ -105,7 +105,7 @@ class CeleryConfig: CELERY_CONFIG = CeleryConfig -FEATURE_FLAGS = {"ALERT_REPORTS": True} +FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True} ALERT_REPORTS_NOTIFICATION_DRY_RUN = True WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501 # The base URL for the email report hyperlinks. diff --git a/requirements/development.txt b/requirements/development.txt index d26c1c78b91..82bb3e76232 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -712,7 +712,7 @@ protobuf==4.25.5 # proto-plus psutil==6.1.0 # via apache-superset -psycopg2-binary==2.9.6 +psycopg2-binary==2.9.9 # via apache-superset py-key-value-aio==0.3.0 # via diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index ca8c56ee12a..e6dc6b753d5 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -59,7 +59,7 @@ module.exports = { ], coverageReporters: ['lcov', 'json-summary', 'html', 'text'], transformIgnorePatterns: [ - 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)', + 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)', ], preset: 'ts-jest', transform: { diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 740f2735abb..1a71e4e365f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -136,6 +136,7 @@ "use-event-callback": "^0.1.0", "use-immer": "^0.11.0", "use-query-params": "^2.2.2", + "uuid": "^13.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "^17.7.2" }, @@ -685,6 +686,20 @@ "node": ">=14.0.0" } }, + "node_modules/@applitools/execution-grid-tunnel/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@applitools/eyes": { "version": "1.38.2", "resolved": "https://registry.npmjs.org/@applitools/eyes/-/eyes-1.38.2.tgz", @@ -14821,6 +14836,20 @@ "storybook": "^8.6.14" } }, + "node_modules/@storybook/addon-actions/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@storybook/addon-backgrounds": { "version": "8.6.14", "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz", @@ -19904,9 +19933,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -58030,17 +58059,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/uvu": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 7e77bcc8230..739cb14b21e 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -218,6 +218,7 @@ "use-event-callback": "^0.1.0", "use-immer": "^0.11.0", "use-query-params": "^2.2.2", + "uuid": "^13.0.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "^17.7.2" }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnTypeLabel/ColumnTypeLabel.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnTypeLabel/ColumnTypeLabel.tsx index 8487b865f45..57988ee9651 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnTypeLabel/ColumnTypeLabel.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/components/ColumnTypeLabel/ColumnTypeLabel.tsx @@ -29,8 +29,9 @@ import { FieldStringOutlined, NumberOutlined, } from '@ant-design/icons'; +import { Icons } from '@superset-ui/core/components'; -export type ColumnLabelExtendedType = 'expression' | ''; +export type ColumnLabelExtendedType = 'expression' | 'metric' | ''; export type ColumnTypeLabelProps = { type?: ColumnLabelExtendedType | GenericDataType; @@ -59,7 +60,9 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) { ); - if (type === '' || type === 'expression') { + if (type === 'metric') { + typeIcon = ; + } else if (type === '' || type === 'expression') { typeIcon = ; } else if (type === GenericDataType.String) { typeIcon = ; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/components/MetricOption.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/components/MetricOption.tsx index bf24d1279f5..1f7f43c4e49 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/components/MetricOption.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/components/MetricOption.tsx @@ -95,7 +95,7 @@ export function MetricOption({ return ( - {showType && } + {showType && } {shouldShowTooltip ? ( {label} diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnTypeLabel.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnTypeLabel.test.tsx index 7943a78e496..a421bfe7a5a 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnTypeLabel.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/components/ColumnTypeLabel.test.tsx @@ -52,6 +52,10 @@ describe('ColumnOption', () => { renderColumnTypeLabel({ type: 'expression' }); expect(screen.getByLabelText('function type icon')).toBeVisible(); }); + it('metric type shows sigma icon', () => { + renderColumnTypeLabel({ type: 'metric' }); + expect(screen.getByLabelText('metric type icon')).toBeVisible(); + }); it('unknown type shows question mark', () => { renderColumnTypeLabel({ type: undefined }); expect(screen.getByLabelText('unknown type icon')).toBeVisible(); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 9c6db6ac6a6..b41f38597ae 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -79,6 +79,7 @@ import { FolderAddOutlined, FolderOpenOutlined, FolderOutlined, + FolderViewOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, @@ -226,6 +227,7 @@ const AntdIcons = { FolderAddOutlined, FolderOpenOutlined, FolderOutlined, + FolderViewOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx index 94e128f9ae5..fa246edae02 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/index.tsx @@ -42,10 +42,12 @@ const customIcons = [ 'Error', 'Full', 'Layers', + 'Move', 'Multiple', 'Queued', 'Redo', 'Running', + 'Sigma', 'Slack', 'Square', 'SortAsc', diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index c892037984d..2bd09edafc2 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -36,6 +36,7 @@ export enum FeatureFlag { DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION', DashboardRbac = 'DASHBOARD_RBAC', DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT', + DatasetFolders = 'DATASET_FOLDERS', DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED', /** @deprecated */ DrillToDetail = 'DRILL_TO_DETAIL', diff --git a/superset-frontend/src/assets/images/icons/move.svg b/superset-frontend/src/assets/images/icons/move.svg new file mode 100644 index 00000000000..673294b5d10 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/move.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/superset-frontend/src/assets/images/icons/sigma.svg b/superset-frontend/src/assets/images/icons/sigma.svg new file mode 100644 index 00000000000..38994adb7f4 --- /dev/null +++ b/superset-frontend/src/assets/images/icons/sigma.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx index 56379a40e29..7b2dd6a6503 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx @@ -23,6 +23,8 @@ import { SupersetClient, getClientErrorObject, SupersetError, + isFeatureEnabled, + FeatureFlag, } from '@superset-ui/core'; import { styled, useTheme, css, Alert } from '@apache-superset/core/ui'; @@ -173,6 +175,10 @@ const DatasourceModal: FunctionComponent = ({ (o: Record) => o.value || o.id, ), }; + // Add folders if DATASET_FOLDERS feature is enabled + if (isFeatureEnabled(FeatureFlag.DatasetFolders) && datasource.folders) { + payload.folders = datasource.folders; + } // Handle catalog based on database's allow_multi_catalog setting // If multi-catalog is disabled, don't include catalog in payload // The backend will use the default catalog @@ -356,7 +362,9 @@ const DatasourceModal: FunctionComponent = ({ ? t( "This dataset is managed externally, and can't be edited in Superset", ) - : '' + : errors.length > 0 + ? errors.join('\n') + : '' } > {t('Save')} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx new file mode 100644 index 00000000000..fbf3080cd14 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx @@ -0,0 +1,545 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ReactElement, ReactChild } from 'react'; +import { + render, + screen, + fireEvent, + waitFor, +} from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { Metric, ColumnMeta } from '@superset-ui/chart-controls'; +import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; +import FoldersEditor from '.'; +import { + DEFAULT_METRICS_FOLDER_UUID, + DEFAULT_COLUMNS_FOLDER_UUID, +} from './constants'; +import { FoldersEditorItemType } from '../types'; + +// Mock react-virtualized-auto-sizer to provide dimensions in tests +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ + children, + }: { + children: (params: { height: number; width: number }) => ReactChild; + }) => + children({ height: 500, width: 400 }), +); + +// Mock react-window VariableSizeList to render all items for testing +jest.mock('react-window', () => ({ + VariableSizeList: ({ + children: Row, + itemCount, + itemData, + }: { + children: React.ComponentType<{ + index: number; + style: React.CSSProperties; + data: unknown; + }>; + itemCount: number; + itemData: unknown; + }) => ( +
+ {Array.from({ length: itemCount }, (_, index) => ( + + ))} +
+ ), +})); + +// Wrap render with useRedux: true since FoldersEditor uses useToasts which requires Redux +const renderEditor = (ui: ReactElement, options = {}) => + render(ui, { useRedux: true, ...options }); + +const mockMetrics: Metric[] = [ + { + uuid: 'metric1', + metric_name: 'Count', + expression: 'COUNT(*)', + description: 'Total count', + }, + { + uuid: 'metric2', + metric_name: 'Sum Revenue', + expression: 'SUM(revenue)', + description: 'Total revenue', + }, +]; + +const mockColumns: ColumnMeta[] = [ + { + uuid: 'col1', + column_name: 'id', + type: 'INTEGER', + }, + { + uuid: 'col2', + column_name: 'name', + type: 'VARCHAR', + }, +]; + +const mockFolders: DatasourceFolder[] = [ + { + uuid: DEFAULT_METRICS_FOLDER_UUID, + type: FoldersEditorItemType.Folder, + name: 'Metrics', + children: [ + { type: FoldersEditorItemType.Metric, uuid: 'metric1', name: 'Count' }, + ], + }, + { + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + type: FoldersEditorItemType.Folder, + name: 'Columns', + children: [ + { type: FoldersEditorItemType.Column, uuid: 'col1', name: 'ID' }, + ], + }, +]; + +const defaultProps = { + folders: mockFolders, + metrics: mockMetrics, + columns: mockColumns, + onChange: jest.fn(), +}; + +test('renders FoldersEditor with folders', () => { + renderEditor(); + + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); +}); + +test('renders search input', () => { + renderEditor(); + + expect( + screen.getByPlaceholderText('Search all metrics & columns'), + ).toBeInTheDocument(); +}); + +test('renders action buttons when in edit mode', () => { + renderEditor(); + + expect(screen.getByText('Add folder')).toBeInTheDocument(); + expect(screen.getByText('Select all')).toBeInTheDocument(); + expect(screen.getByText('Reset all folders to default')).toBeInTheDocument(); +}); + +test('adds a new folder when Add folder button is clicked', async () => { + renderEditor(); + + const addButton = screen.getByText('Add folder'); + fireEvent.click(addButton); + + // New folder appears in the UI with an empty input and placeholder + await waitFor(() => { + const input = screen.getByPlaceholderText( + 'Name your folder and to edit it later, click on the folder name', + ); + expect(input).toBeInTheDocument(); + }); +}); + +test('filters items when searching', async () => { + renderEditor(); + + const searchInput = screen.getByPlaceholderText( + 'Search all metrics & columns', + ); + await userEvent.type(searchInput, 'Count'); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + }); +}); + +test('selects all items when Select all is clicked', async () => { + renderEditor(); + + const selectAllButton = screen.getByText('Select all'); + fireEvent.click(selectAllButton); + + await waitFor(() => { + const checkboxes = screen.getAllByRole('checkbox'); + const nonButtonCheckboxes = checkboxes.filter( + checkbox => !checkbox.closest('button'), + ); + expect(nonButtonCheckboxes.length).toBeGreaterThan(0); + nonButtonCheckboxes.forEach(checkbox => { + expect(checkbox).toBeChecked(); + }); + }); +}); + +test('expands and collapses folders', async () => { + renderEditor(); + + // Folder should be expanded by default, so Count should be visible + expect(screen.getByText('Count')).toBeInTheDocument(); + + // Click to collapse - click on the DownOutlined icon to toggle folder + const downIcons = screen.getAllByRole('img', { name: 'down' }); + fireEvent.click(downIcons[0]); + + await waitFor(() => { + expect(screen.queryByText('Count')).not.toBeInTheDocument(); + }); + + // Click to expand again - the icon should now be RightOutlined + const rightIcons = screen.getAllByRole('img', { name: 'right' }); + fireEvent.click(rightIcons[0]); + + await waitFor(() => { + expect(screen.getByText('Count')).toBeInTheDocument(); + }); +}); + +test('edits folder name when clicked in edit mode', async () => { + const onChange = jest.fn(); + renderEditor( + , + ); + + const folderName = screen.getByText('Custom Folder'); + fireEvent.click(folderName); + + const input = screen.getByDisplayValue('Custom Folder'); + await userEvent.clear(input); + await userEvent.type(input, 'Updated Folder'); + fireEvent.blur(input); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + const folders = lastCall[0]; + expect(folders[0].name).toBe('Updated Folder'); + }); +}); + +test('creates default folders when none exist', () => { + renderEditor(); + + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); +}); + +test('shows confirmation modal when resetting to default', async () => { + renderEditor(); + + const resetButton = screen.getByText('Reset all folders to default'); + fireEvent.click(resetButton); + + await waitFor(() => { + // Modal may render multiple elements with the same text (e.g., in portal) + const modalTexts = screen.getAllByText('Reset to default folders?'); + expect(modalTexts.length).toBeGreaterThan(0); + }); +}); + +test('renders sortable drag handles for folders', () => { + renderEditor( + , + ); + + // @dnd-kit adds aria-roledescription="sortable" to sortable elements + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThanOrEqual(2); +}); + +test('applies @dnd-kit dragging styles when folder is being dragged', () => { + renderEditor( + , + ); + + // @dnd-kit adds aria-roledescription="sortable" and role="button" to sortable elements + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); + + // Each sortable element should have @dnd-kit attributes + sortableElements.forEach(element => { + expect(element).toHaveAttribute('aria-roledescription', 'sortable'); + expect(element).toHaveAttribute('role', 'button'); + }); +}); + +test('renders @dnd-kit sortable context', () => { + renderEditor(); + + // Just test that the basic DndContext is working + // by checking for the presence of @dnd-kit specific attributes + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); + + // Test that sortable attributes are present + sortableElements.forEach(element => { + expect(element).toHaveAttribute('aria-roledescription', 'sortable'); + }); +}); + +test('folders are rendered with proper @dnd-kit integration', () => { + renderEditor( + , + ); + + // Test that the folder appears and has drag functionality + expect(screen.getByText('Test Folder')).toBeInTheDocument(); + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); + const sortableElement = sortableElements[0]; + expect(sortableElement).toHaveAttribute('tabindex', '0'); + expect(sortableElement).toHaveAttribute('role', 'button'); +}); + +test('items are sortable with @dnd-kit', () => { + const testProps = { + ...defaultProps, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric-1', + type: FoldersEditorItemType.Metric, + name: 'Test Metric 1', + }, + ], + }, + ], + }; + + renderEditor(); + + // Expand folder to show items + const metricsFolder = screen.getByText('Metrics'); + fireEvent.click(metricsFolder); + + // Check that items have checkboxes + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); + + // Check that sortable elements with @dnd-kit attributes exist + // Items should have sortable attributes even without explicit drag handles + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); +}); + +test('component renders with proper drag and drop structure', () => { + renderEditor(); + + // Verify basic structure is present + expect( + screen.getByPlaceholderText('Search all metrics & columns'), + ).toBeInTheDocument(); + expect(screen.getByText('Add folder')).toBeInTheDocument(); + + // Verify DndContext and sortable elements are working + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); + + // Each sortable element should have @dnd-kit attributes + sortableElements.forEach(element => { + expect(element).toHaveAttribute('aria-roledescription', 'sortable'); + expect(element).toHaveAttribute('role', 'button'); + }); +}); + +test('drag functionality integrates properly with selection state', () => { + const onChange = jest.fn(); + const testProps = { + ...defaultProps, + onChange, + folders: [ + { + type: FoldersEditorItemType.Folder as const, + uuid: DEFAULT_METRICS_FOLDER_UUID, + name: 'Metrics', + children: [ + { + uuid: 'metric-1', + type: FoldersEditorItemType.Metric, + name: 'Test Metric 1', + }, + { + uuid: 'metric-2', + type: FoldersEditorItemType.Metric, + name: 'Test Metric 2', + }, + ], + }, + ], + metrics: [ + { + uuid: 'metric-1', + metric_name: 'Test Metric 1', + expression: 'COUNT(*)', + }, + { + uuid: 'metric-2', + metric_name: 'Test Metric 2', + expression: 'SUM(amount)', + }, + ], + }; + + renderEditor(); + + // Expand folder to show items + const metricsFolder = screen.getByText('Metrics'); + fireEvent.click(metricsFolder); + + // Verify that drag and drop context is properly set up + // Items should be wrapped in sortable context + const sortableItems = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableItems.length).toBeGreaterThanOrEqual(2); // At least folders are sortable + + // Verify checkboxes are present and functional + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); +}); + +test('nested folders with items remain visible after drag is cancelled', async () => { + const onChange = jest.fn(); + const nestedFolders: DatasourceFolder[] = [ + { + uuid: 'parent-folder', + type: FoldersEditorItemType.Folder, + name: 'Parent Folder', + children: [ + { + uuid: 'nested-folder', + type: FoldersEditorItemType.Folder, + name: 'Nested Folder', + children: [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Count', + }, + ], + } as DatasourceFolder, + ], + }, + ]; + + renderEditor( + , + ); + + expect(screen.getByText('Parent Folder')).toBeInTheDocument(); + expect(screen.getByText('Nested Folder')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + + const sortableElements = document.querySelectorAll( + '[aria-roledescription="sortable"]', + ); + expect(sortableElements.length).toBeGreaterThan(0); + + await waitFor(() => { + expect(screen.getByText('Parent Folder')).toBeInTheDocument(); + expect(screen.getByText('Nested Folder')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts new file mode 100644 index 00000000000..b28fa0fe985 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts @@ -0,0 +1,214 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { styled, css } from '@apache-superset/core/ui'; + +export const FOLDER_INDENTATION_WIDTH = 24; +export const ITEM_INDENTATION_WIDTH = 4; + +export const TreeItemContainer = styled.div<{ + depth: number; + isDragging: boolean; + isOver: boolean; + isOverlay?: boolean; +}>` + ${({ theme, depth, isDragging, isOverlay }) => ` + margin: 0 ${theme.marginMD}px; + margin-left: ${isOverlay ? ITEM_INDENTATION_WIDTH : (depth - 1) * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px; + padding-left: ${theme.paddingSM}px; + display: flex; + align-items: center; + cursor: pointer; + opacity: ${isDragging ? 0.4 : 1}; + user-select: none; + ${isDragging || isOverlay ? 'will-change: transform;' : ''} + `} +`; + +export const ItemSeparator = styled.div<{ + variant: 'visible' | 'transparent'; +}>` + ${({ theme, variant }) => { + // Use explicit height instead of margins so dnd-kit measures correctly. + // getBoundingClientRect doesn't include margins, causing transform mismatches during drag. + const verticalPadding = + variant === 'visible' ? theme.marginSM : theme.marginXS; + const totalHeight = 1 + verticalPadding * 2; + return ` + height: ${totalHeight}px; + display: flex; + align-items: center; + margin-left: ${theme.marginSM}px; + margin-right: ${theme.marginMD}px; + + &::after { + content: ''; + display: block; + width: 100%; + height: 1px; + background-color: ${variant === 'visible' ? theme.colorBorderSecondary : 'transparent'}; + } + `; + }} +`; + +export const TreeFolderContainer = styled(TreeItemContainer)<{ + isForbiddenDropTarget?: boolean; +}>` + ${({ theme, depth, isForbiddenDropTarget, isOverlay }) => ` + margin-top: 0; + margin-bottom: 0; + padding-top: ${theme.paddingSM}px; + padding-bottom: ${theme.paddingSM}px; + margin-left: ${depth * FOLDER_INDENTATION_WIDTH}px; + border-radius: ${theme.borderRadius}px; + padding-left: ${theme.paddingSM}px; + padding-right: ${theme.paddingSM}px; + margin-right: ${theme.marginMD}px; + transition: background-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + + /* Drop target styles - controlled via data attributes for performance */ + &[data-drop-target="true"] { + background-color: ${theme.colorPrimaryBg}; + box-shadow: inset 0 0 0 2px ${theme.colorPrimary}; + } + + &[data-drop-target="true"][data-forbidden-drop="true"], + &[data-drop-target="true"]${isForbiddenDropTarget ? '' : '[data-forbidden-drop="true"]'} { + background-color: ${theme.colorErrorBg}; + box-shadow: inset 0 0 0 2px ${theme.colorError}; + cursor: not-allowed; + } + + /* Also support prop-based forbidden styling for initial render */ + ${ + isForbiddenDropTarget + ? ` + &[data-drop-target="true"] { + background-color: ${theme.colorErrorBg}; + box-shadow: inset 0 0 0 2px ${theme.colorError}; + cursor: not-allowed; + } + ` + : '' + } + `} +`; + +export const DragHandle = styled.span` + ${({ theme }) => ` + color: ${theme.colorTextTertiary}; + display: inline-flex; + align-items: center; + cursor: grab; + + &:hover { + color: ${theme.colorText}; + } + + &:active { + cursor: grabbing; + } + `} +`; + +export const CollapseButton = styled.span` + ${({ theme }) => ` + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + cursor: pointer; + color: ${theme.colorTextSecondary}; + margin-left: auto; + + &:hover { + color: ${theme.colorText}; + } + `} +`; + +export const DefaultFolderIconContainer = styled.span` + ${({ theme }) => ` + display: inline-flex; + align-items: center; + color: ${theme.colorTextSecondary}; + margin-right: ${theme.marginXS}px; + `} +`; + +export const FolderName = styled.span` + ${({ theme }) => ` + margin-right: ${theme.marginMD}px; + font-weight: ${theme.fontWeightStrong}; + `} +`; + +export const DragHandleContainer = styled.div` + ${({ theme }) => ` + height: 100%; + display: flex; + align-items: center; + padding: 0 ${theme.sizeUnit}px; + margin-left: auto; + cursor: grab; + color: ${theme.colorTextTertiary}; + + &:hover { + color: ${theme.colorText}; + } + + &:active { + cursor: grabbing; + } + `} +`; + +export const EmptyFolderDropZone = styled.div<{ + depth: number; + isOver: boolean; + isForbidden: boolean; +}>` + ${({ theme, depth, isOver, isForbidden }) => css` + margin: ${theme.marginXS}px ${theme.marginMD}px 0; + margin-left: ${depth * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px; + padding: ${theme.paddingLG}px; + border: 2px dashed + ${isOver + ? isForbidden + ? theme.colorError + : theme.colorPrimary + : 'transparent'}; + border-radius: ${theme.borderRadius}px; + background: ${isOver + ? isForbidden + ? theme.colorErrorBg + : theme.colorPrimaryBg + : 'transparent'}; + text-align: center; + transition: all 0.2s ease-in-out; + cursor: ${isOver && isForbidden ? 'not-allowed' : 'default'}; + opacity: ${isOver && isForbidden ? 0.7 : 1}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + `} +`; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx new file mode 100644 index 00000000000..a941fd19474 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx @@ -0,0 +1,396 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CSSProperties, useState, memo, useMemo } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { useDroppable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { css, t } from '@apache-superset/core/ui'; +import { + Checkbox, + Input, + Icons, + EmptyState, + Tooltip, +} from '@superset-ui/core/components'; +import { + ColumnLabelExtendedType, + ColumnMeta, + ColumnTypeLabel, + Metric, +} from '@superset-ui/chart-controls'; +import { GenericDataType } from '@apache-superset/core/api/core'; +import { + OptionControlContainer, + Label, +} from 'src/explore/components/controls/OptionControls'; +import { FoldersEditorItemType } from '../types'; +import { + DEFAULT_COLUMNS_FOLDER_UUID, + DEFAULT_METRICS_FOLDER_UUID, +} from './constants'; +import { + TreeItemContainer, + TreeFolderContainer, + DragHandle, + CollapseButton, + DefaultFolderIconContainer, + FolderName, + DragHandleContainer, + EmptyFolderDropZone, + ItemSeparator, +} from './TreeItem.styles'; + +const FOLDER_NAME_PLACEHOLDER = t( + 'Name your folder and to edit it later, click on the folder name', +); + +interface TreeItemProps { + id: string; + type: FoldersEditorItemType; + name: string; + depth: number; + isCollapsed?: boolean; + isFolder?: boolean; + isSelected?: boolean; + isEditing?: boolean; + onToggleCollapse?: (id: string) => void; + onSelect?: (id: string, selected: boolean, shiftKey?: boolean) => void; + onStartEdit?: (id: string) => void; + onFinishEdit?: (id: string, newName: string) => void; + isDefaultFolder?: boolean; + showEmptyState?: boolean; + separatorType?: 'visible' | 'transparent'; + isForbiddenDrop?: boolean; + isDropTarget?: boolean; + metric?: Metric; + column?: ColumnMeta; + isOverlay?: boolean; +} + +function TreeItemComponent({ + id, + type, + name, + depth, + isCollapsed = false, + isFolder = false, + isSelected = false, + isEditing = false, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, + isDefaultFolder = false, + showEmptyState = false, + separatorType, + isForbiddenDrop = false, + isDropTarget = false, + metric, + column, + isOverlay = false, +}: TreeItemProps) { + const [editValue, setEditValue] = useState(name); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + isOver, + } = useSortable({ + id, + data: { + type, + isFolder, + }, + disabled: isOverlay, + }); + + const { setNodeRef: setDroppableRef, isOver: isOverEmpty } = useDroppable({ + id: `${id}-empty`, + data: { + type, + isFolder, + parentId: id, + }, + disabled: isOverlay, + }); + + const style: CSSProperties = isOverlay + ? {} + : { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onFinishEdit?.(id, editValue); + } else if (e.key === 'Escape') { + setEditValue(name); + onFinishEdit?.(id, name); + } + }; + + const handleEditBlur = () => { + if (editValue.trim()) { + onFinishEdit?.(id, editValue); + } else { + setEditValue(name); + onFinishEdit?.(id, name); + } + }; + + const itemDisplayName = useMemo(() => { + if (type === FoldersEditorItemType.Metric && metric) { + return metric.verbose_name || metric.metric_name || name; + } + if (type === FoldersEditorItemType.Column && column) { + return column.verbose_name || column.column_name || name; + } + return name; + }, [type, metric, column, name]); + + const columnType: ColumnLabelExtendedType | GenericDataType | undefined = + useMemo(() => { + if (type === FoldersEditorItemType.Metric) { + return 'metric'; + } + if (type === FoldersEditorItemType.Column && column) { + const hasExpression = + column.expression && column.expression !== column.column_name; + return hasExpression ? 'expression' : column.type_generic; + } + return undefined; + }, [type, column]); + + const hasEmptyName = !name || name.trim() === ''; + + const renderItemContent = () => { + if (isFolder) { + const isDefaultColumnsFolder = + id === DEFAULT_COLUMNS_FOLDER_UUID && isDefaultFolder; + const isDefaultMetricsFolder = + id === DEFAULT_METRICS_FOLDER_UUID && isDefaultFolder; + const folderNameContent = ( + { + if (!isDefaultFolder && onStartEdit) { + e.stopPropagation(); + onStartEdit(id); + } + }} + > + {name} + + ); + + if (isDefaultColumnsFolder) { + return ( + + {folderNameContent} + + ); + } + + if (isDefaultMetricsFolder) { + return ( + + {folderNameContent} + + ); + } + + return folderNameContent; + } + + return ( + + + + + + + ); + }; + + const containerProps = { + ref: setNodeRef, + style, + depth, + isDragging, + isOver, + isOverlay, + }; + + const containerContent = ( + <> + {isFolder && ( + css` + margin-right: ${theme.marginSM}px; + `} + > + + + )} + + {(onSelect || (isOverlay && !isFolder)) && ( + { + if (!isOverlay) { + e.stopPropagation(); + onSelect?.(id, !isSelected, e.shiftKey); + } + }} + css={theme => css` + margin-right: ${theme.marginSM}px; + `} + /> + )} + + {isFolder && ( + + {isDefaultFolder ? ( + + ) : ( + + )} + + )} + + {(isEditing || hasEmptyName) && !isDefaultFolder ? ( + ) => + setEditValue(e.target.value) + } + onKeyDown={handleEditKeyDown} + onBlur={handleEditBlur} + autoFocus + onClick={(e: React.MouseEvent) => + e.stopPropagation() + } + css={theme => css` + padding: 0; + padding-right: ${theme.marginMD}px; + `} + variant="borderless" + /> + ) : ( + renderItemContent() + )} + + {isFolder && onToggleCollapse && ( + { + e.stopPropagation(); + onToggleCollapse(id); + }} + > + {isCollapsed ? : } + + )} + + ); + + // Separator appears BELOW items (after content) + // Wrapped together with item so they move as one unit during drag + const showSeparator = !isFolder && separatorType; + + // Extract transform style to apply to wrapper + const { style: transformStyle, ...restContainerProps } = containerProps; + + return ( + <> + {/* Wrapper div receives the transform so item + separator move together */} +
+ {isFolder ? ( + + {containerContent} + + ) : ( + + {containerContent} + + )} + + {showSeparator && } +
+ + {isFolder && showEmptyState && !isCollapsed && ( + + + + )} + + ); +} + +export const TreeItem = memo(TreeItemComponent); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx new file mode 100644 index 00000000000..39fa42d19df --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx @@ -0,0 +1,220 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CSSProperties, memo } from 'react'; +import type { ListChildComponentProps } from 'react-window'; +import { useDroppable } from '@dnd-kit/core'; +import type { UniqueIdentifier } from '@dnd-kit/core'; +import type { Metric, ColumnMeta } from '@superset-ui/chart-controls'; +import { FoldersEditorItemType } from '../types'; +import type { FlattenedTreeItem } from './constants'; +import { isDefaultFolder } from './constants'; +import { TreeItem } from './TreeItem'; + +// Invisible placeholder that keeps the droppable area for horizontal drag depth changes +interface DragPlaceholderProps { + id: string; + style: CSSProperties; + type: FoldersEditorItemType; + isFolder: boolean; +} + +const DragPlaceholder = memo(function DragPlaceholder({ + id, + style, + type, + isFolder, +}: DragPlaceholderProps) { + const { setNodeRef } = useDroppable({ + id, + data: { type, isFolder }, + }); + + return
; +}); + +export interface VirtualizedTreeItemData { + flattenedItems: FlattenedTreeItem[]; + collapsedIds: Set; + selectedItemIds: Set; + editingFolderId: string | null; + folderChildCounts: Map; + itemSeparatorInfo: Map; + visibleItemIds: Set; + searchTerm: string; + metricsMap: Map; + columnsMap: Map; + activeId: UniqueIdentifier | null; + forbiddenDropFolderIds: Set; + currentDropTargetId: string | null; + onToggleCollapse: (id: string) => void; + onSelect: (id: string, selected: boolean, shiftKey?: boolean) => void; + onStartEdit: (id: string) => void; + onFinishEdit: (id: string, newName: string) => void; +} + +// Inner component that receives state as props for proper memoization +interface TreeItemWrapperProps { + item: FlattenedTreeItem; + style: CSSProperties; + isFolder: boolean; + isCollapsed: boolean; + isSelected: boolean; + isEditing: boolean; + showEmptyState: boolean; + separatorType?: 'visible' | 'transparent'; + isForbiddenDrop: boolean; + isDropTarget: boolean; + metric?: Metric; + column?: ColumnMeta; + onToggleCollapse?: (id: string) => void; + onSelect?: (id: string, selected: boolean, shiftKey?: boolean) => void; + onStartEdit?: (id: string) => void; + onFinishEdit?: (id: string, newName: string) => void; +} + +const TreeItemWrapper = memo(function TreeItemWrapper({ + item, + style, + isFolder, + isCollapsed, + isSelected, + isEditing, + showEmptyState, + separatorType, + isForbiddenDrop, + isDropTarget, + metric, + column, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, +}: TreeItemWrapperProps) { + return ( +
+ +
+ ); +}); + +function VirtualizedTreeItemComponent({ + index, + style, + data, +}: ListChildComponentProps) { + const { + flattenedItems, + collapsedIds, + selectedItemIds, + editingFolderId, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + searchTerm, + metricsMap, + columnsMap, + activeId, + forbiddenDropFolderIds, + currentDropTargetId, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, + } = data; + + const item = flattenedItems[index]; + + if (!item) { + return null; + } + + const isFolder = item.type === FoldersEditorItemType.Folder; + + // Hide items that don't match search (unless they're folders) + if (!isFolder && searchTerm && !visibleItemIds.has(item.uuid)) { + return null; + } + + // Render invisible placeholder for active dragged item - keeps droppable area + // for horizontal drag depth changes while visual is in DragOverlay + if (activeId === item.uuid) { + return ( + + ); + } + + const childCount = isFolder ? (folderChildCounts.get(item.uuid) ?? 0) : 0; + const showEmptyState = isFolder && childCount === 0; + + // isForbiddenDrop is calculated from props (changes when dragged items change) + const isForbiddenDrop = isFolder && forbiddenDropFolderIds.has(item.uuid); + + // isDropTarget indicates this folder is the current drop target + const isDropTarget = isFolder && currentDropTargetId === item.uuid; + + return ( + + ); +} + +export const VirtualizedTreeItem = memo(VirtualizedTreeItemComponent); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx new file mode 100644 index 00000000000..1920b296198 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx @@ -0,0 +1,229 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { VariableSizeList as List } from 'react-window'; +import type { UniqueIdentifier } from '@dnd-kit/core'; +import type { Metric, ColumnMeta } from '@superset-ui/chart-controls'; +import { FoldersEditorItemType } from '../types'; +import type { FlattenedTreeItem } from './constants'; +import type { ItemHeights } from './hooks/useItemHeights'; +import type { HeightCache } from './hooks/useHeightCache'; +import { useAutoScroll } from './hooks/useAutoScroll'; +import { + VirtualizedTreeItem, + VirtualizedTreeItemData, +} from './VirtualizedTreeItem'; + +interface VirtualizedTreeListProps { + width: number; + height: number; + flattenedItems: FlattenedTreeItem[]; + itemHeights: ItemHeights; + heightCache: HeightCache; + collapsedIds: Set; + selectedItemIds: Set; + editingFolderId: string | null; + folderChildCounts: Map; + itemSeparatorInfo: Map; + visibleItemIds: Set; + searchTerm: string; + metricsMap: Map; + columnsMap: Map; + isDragging: boolean; + activeId: UniqueIdentifier | null; + forbiddenDropFolderIds: Set; + currentDropTargetId: string | null; + onToggleCollapse: (id: string) => void; + onSelect: (id: string, selected: boolean, shiftKey?: boolean) => void; + onStartEdit: (id: string) => void; + onFinishEdit: (id: string, newName: string) => void; +} + +export function VirtualizedTreeList({ + width, + height, + flattenedItems, + itemHeights, + heightCache, + collapsedIds, + selectedItemIds, + editingFolderId, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + searchTerm, + metricsMap, + columnsMap, + isDragging, + activeId, + forbiddenDropFolderIds, + currentDropTargetId, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, +}: VirtualizedTreeListProps) { + const listRef = useRef(null); + const containerRef = useRef(null); + + // Custom auto-scroll during drag (replaces dnd-kit's auto-scroll which conflicts with virtualization) + useAutoScroll({ + listRef, + containerRef, + isDragging, + listHeight: height, + }); + + // Reset list cache when items structure changes, but not during drag + // Resetting during drag causes jumping/flickering + useEffect(() => { + if (!isDragging) { + listRef.current?.resetAfterIndex(0); + } + }, [ + flattenedItems, + collapsedIds, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + isDragging, + ]); + + // Calculate item size for react-window + const getItemSize = useCallback( + (index: number): number => { + const item = flattenedItems[index]; + + if (!item) { + return 0; + } + + const isFolder = item.type === FoldersEditorItemType.Folder; + + // If item doesn't match search, return 0 (hidden) + if (!isFolder && searchTerm && !visibleItemIds.has(item.uuid)) { + return 0; + } + + // Keep the slot height for the active dragged item so horizontal drag + // can detect "over self" for depth changes. The visual is hidden but + // the droppable area remains. + + let totalHeight = 0; + + if (isFolder) { + totalHeight = itemHeights.folderHeader; + + // Add EmptyState height if folder is empty and expanded + const childCount = folderChildCounts.get(item.uuid) ?? 0; + const isCollapsed = collapsedIds.has(item.uuid); + + if (childCount === 0 && !isCollapsed) { + // Use cached height for empty folder or fall back to estimate + totalHeight += + heightCache.getHeight(item.uuid) ?? itemHeights.emptyFolderBase; + } + } else { + totalHeight = itemHeights.regularItem; + } + + // Add separator height if this item has one + const separatorType = itemSeparatorInfo.get(item.uuid); + if (separatorType === 'visible') { + totalHeight += itemHeights.separatorVisible; + } else if (separatorType === 'transparent') { + totalHeight += itemHeights.separatorTransparent; + } + + return totalHeight; + }, + [ + flattenedItems, + itemHeights, + heightCache, + collapsedIds, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + searchTerm, + ], + ); + + // Prepare item data for the row renderer + const itemData: VirtualizedTreeItemData = useMemo( + () => ({ + flattenedItems, + collapsedIds, + selectedItemIds, + editingFolderId, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + searchTerm, + metricsMap, + columnsMap, + activeId, + forbiddenDropFolderIds, + currentDropTargetId, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, + }), + [ + flattenedItems, + collapsedIds, + selectedItemIds, + editingFolderId, + folderChildCounts, + itemSeparatorInfo, + visibleItemIds, + searchTerm, + metricsMap, + columnsMap, + activeId, + forbiddenDropFolderIds, + currentDropTargetId, + onToggleCollapse, + onSelect, + onStartEdit, + onFinishEdit, + ], + ); + + // Use higher overscan during drag to ensure smooth scrolling + const overscanCount = isDragging ? 20 : 5; + + return ( +
+ + {VirtualizedTreeItem} + +
+ ); +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx new file mode 100644 index 00000000000..b14ab8d3344 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo } from 'react'; +import { Metric } from '@superset-ui/core'; +import { ColumnMeta } from '@superset-ui/chart-controls'; +import { FoldersEditorItemType } from '../../types'; +import { FlattenedTreeItem } from '../constants'; +import { TreeItem } from '../TreeItem'; +import { DragOverlayStack, DragOverlayItem } from '../styles'; + +interface DragOverlayContentProps { + dragOverlayItems: FlattenedTreeItem[]; + dragOverlayWidth: number | null; + selectedItemIds: Set; + metricsMap: Map; + columnsMap: Map; +} + +function DragOverlayContentInner({ + dragOverlayItems, + dragOverlayWidth, + selectedItemIds, + metricsMap, + columnsMap, +}: DragOverlayContentProps) { + if (dragOverlayItems.length === 0) { + return null; + } + + return ( + + {[...dragOverlayItems].reverse().map((item, index) => { + const stackIndex = dragOverlayItems.length - 1 - index; + return ( + + + + ); + })} + + ); +} + +export const DragOverlayContent = memo(DragOverlayContentInner); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx new file mode 100644 index 00000000000..8fe6718c254 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx @@ -0,0 +1,78 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo } from 'react'; +import { t } from '@apache-superset/core'; +import { Button, Input } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { FoldersToolbar, FoldersSearch, FoldersActions } from '../styles'; + +interface FoldersToolbarComponentProps { + onSearch: (e: React.ChangeEvent) => void; + onAddFolder: () => void; + onSelectAll: () => void; + onResetToDefault: () => void; + allVisibleSelected: boolean; +} + +function FoldersToolbarComponentInner({ + onSearch, + onAddFolder, + onSelectAll, + onResetToDefault, + allVisibleSelected, +}: FoldersToolbarComponentProps) { + return ( + + + } + /> + + + + + + + + ); +} + +export const FoldersToolbarComponent = memo(FoldersToolbarComponentInner); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx new file mode 100644 index 00000000000..0aabc92d8ac --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memo } from 'react'; +import { t } from '@apache-superset/core'; +import { Modal } from '@superset-ui/core/components'; + +interface ResetConfirmModalProps { + show: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +function ResetConfirmModalInner({ + show, + onCancel, + onConfirm, +}: ResetConfirmModalProps) { + return ( + + {t( + 'This will reorganize all metrics and columns into default folders. Any custom folders will be removed.', + )} + + ); +} + +export const ResetConfirmModal = memo(ResetConfirmModalInner); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts b/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts new file mode 100644 index 00000000000..4576f81ac36 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts @@ -0,0 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { FoldersToolbarComponent } from './FoldersToolbarComponent'; +export { ResetConfirmModal } from './ResetConfirmModal'; +export { DragOverlayContent } from './DragOverlayContent'; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts new file mode 100644 index 00000000000..fc9f4d189f8 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + DatasourceFolder, + DatasourceFolderItem, +} from 'src/explore/components/DatasourcePanel/types'; +import { FoldersEditorItemType } from '../types'; + +// Default folder UUIDs +export const DEFAULT_METRICS_FOLDER_UUID = + '255b537d-58c8-443d-9fc1-4e4dc75047e2'; +export const DEFAULT_COLUMNS_FOLDER_UUID = + '83a7ae8f-2e8a-4f2b-a8cb-ebaebef95b9b'; + +// Drag & drop constants +export const DRAG_INDENTATION_WIDTH = 64; +export const MAX_DEPTH = 3; + +// Type definitions +export type TreeItem = DatasourceFolder | DatasourceFolderItem; + +export interface FlattenedTreeItem { + uuid: string; + type: FoldersEditorItemType; + name: string; + description?: string; + children?: TreeItem[]; + parentId: string | null; + depth: number; + index: number; + collapsed?: boolean; +} + +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +// Helper functions +export const isDefaultFolder = (folderId: string): boolean => + folderId === DEFAULT_METRICS_FOLDER_UUID || + folderId === DEFAULT_COLUMNS_FOLDER_UUID; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts new file mode 100644 index 00000000000..df7e1ea9280 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts @@ -0,0 +1,217 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Metric } from '@superset-ui/chart-controls'; +import { ColumnObject } from 'src/features/datasets/types'; +import { + DEFAULT_METRICS_FOLDER_UUID, + DEFAULT_COLUMNS_FOLDER_UUID, + isDefaultFolder, +} from './constants'; +import { + createFolder, + resetToDefault, + filterItemsBySearch, + ensureDefaultFolders, +} from './folderOperations'; +import { validateFolders } from './folderValidation'; +import { FoldersEditorItemType } from '../types'; + +describe('folderUtils', () => { + const mockMetrics: Metric[] = [ + { + id: 1, + uuid: 'metric-1', + metric_name: 'Test Metric 1', + metric_type: 'count', + expression: 'COUNT(*)', + } as Metric, + { + id: 2, + uuid: 'metric-2', + metric_name: 'Test Metric 2', + metric_type: 'sum', + expression: 'SUM(value)', + } as Metric, + ]; + + const mockColumns: (ColumnObject & { uuid: string })[] = [ + { + id: 1, + uuid: 'column-1', + column_name: 'Test Column 1', + type: 'VARCHAR', + filterable: true, + groupby: true, + is_active: true, + is_dttm: false, + }, + { + id: 2, + uuid: 'column-2', + column_name: 'Test Column 2', + type: 'INTEGER', + filterable: true, + groupby: true, + is_active: true, + is_dttm: false, + }, + ]; + + describe('createFolder', () => { + test('should create a folder with correct properties', () => { + const folder = createFolder('Test Folder'); + + expect(folder.name).toBe('Test Folder'); + expect(folder.type).toBe(FoldersEditorItemType.Folder); + expect(folder.children).toEqual([]); + expect(folder.uuid).toBeDefined(); + }); + }); + + describe('resetToDefault', () => { + test('should create default folders with correct structure', () => { + const result = resetToDefault(mockMetrics, mockColumns); + + expect(result).toHaveLength(2); + + const metricsFolder = result.find( + f => f.uuid === DEFAULT_METRICS_FOLDER_UUID, + ); + const columnsFolder = result.find( + f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID, + ); + + expect(metricsFolder).toBeDefined(); + expect(metricsFolder?.name).toBe('Metrics'); + expect(metricsFolder?.children).toHaveLength(2); + + expect(columnsFolder).toBeDefined(); + expect(columnsFolder?.name).toBe('Columns'); + expect(columnsFolder?.children).toHaveLength(2); + }); + }); + + describe('filterItemsBySearch', () => { + test('should filter items by search term', () => { + const allItems = [...mockMetrics, ...mockColumns]; + const result = filterItemsBySearch('Test Metric', allItems); + + expect(result.size).toBe(2); + expect(result.has('metric-1')).toBe(true); + expect(result.has('metric-2')).toBe(true); + }); + + test('should return all items for empty search', () => { + const allItems = [...mockMetrics, ...mockColumns]; + const result = filterItemsBySearch('', allItems); + + expect(result.size).toBe(4); + }); + }); + + describe('isDefaultFolder', () => { + test('should identify default folders by UUID', () => { + expect(isDefaultFolder(DEFAULT_METRICS_FOLDER_UUID)).toBe(true); + expect(isDefaultFolder(DEFAULT_COLUMNS_FOLDER_UUID)).toBe(true); + expect(isDefaultFolder('custom-folder-uuid')).toBe(false); + }); + }); + + describe('validateFolders', () => { + test('should validate folders successfully', () => { + const folders = resetToDefault(mockMetrics, mockColumns); + const result = validateFolders(folders); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('should allow empty folders without names', () => { + // Empty folders without names are valid (they get filtered out anyway) + const folders = [createFolder('')]; + const result = validateFolders(folders); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test('should detect folders with content but no name', () => { + const folder = createFolder(''); + folder.children = [ + { uuid: 'metric-1', type: FoldersEditorItemType.Metric, name: 'Test' }, + ]; + const folders = [folder]; + const result = validateFolders(folders); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Folder with content must have a name'); + }); + + test('should detect duplicate folder names', () => { + const folder1 = createFolder('My Folder'); + const folder2 = createFolder('My Folder'); + const folders = [folder1, folder2]; + const result = validateFolders(folders); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.includes('my folder'))).toBe(true); + }); + + test('should detect duplicate folder names case-insensitively', () => { + const folder1 = createFolder('Test Folder'); + const folder2 = createFolder('test folder'); + const folders = [folder1, folder2]; + const result = validateFolders(folders); + + expect(result.isValid).toBe(false); + expect(result.errors.some(e => e.includes('test folder'))).toBe(true); + }); + }); + + describe('ensureDefaultFolders', () => { + test('should create default folders when none exist', () => { + const result = ensureDefaultFolders([], mockMetrics, mockColumns); + + expect(result).toHaveLength(2); + + const metricsFolder = result.find( + f => f.uuid === DEFAULT_METRICS_FOLDER_UUID, + ); + const columnsFolder = result.find( + f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID, + ); + + expect(metricsFolder).toBeDefined(); + expect(columnsFolder).toBeDefined(); + }); + + test('should preserve existing folders', () => { + const existingFolders = [createFolder('Custom Folder')]; + const result = ensureDefaultFolders( + existingFolders, + mockMetrics, + mockColumns, + ); + + expect(result.length).toBeGreaterThan(2); + expect(result.find(f => f.name === 'Custom Folder')).toBeDefined(); + }); + }); +}); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts new file mode 100644 index 00000000000..852de41f7f9 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts @@ -0,0 +1,217 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Folder CRUD operations and data mutations. + * Handles creating, deleting, renaming, moving folders and items. + */ + +import { Metric, ColumnMeta } from '@superset-ui/chart-controls'; +import { t } from '@apache-superset/core'; +import { v4 as uuidv4 } from 'uuid'; +import { + DatasourceFolder, + DatasourceFolderItem, +} from 'src/explore/components/DatasourcePanel/types'; +import { FoldersEditorItemType } from '../types'; +import { + DEFAULT_METRICS_FOLDER_UUID, + DEFAULT_COLUMNS_FOLDER_UUID, +} from './constants'; + +export const createFolder = (name: string): DatasourceFolder => ({ + uuid: uuidv4(), + type: FoldersEditorItemType.Folder, + name, + children: [], +}); + +export const resetToDefault = ( + metrics: Metric[], + columns: ColumnMeta[], +): DatasourceFolder[] => { + const metricsFolder: DatasourceFolder = { + uuid: DEFAULT_METRICS_FOLDER_UUID, + type: FoldersEditorItemType.Folder, + name: t('Metrics'), + children: metrics.map(m => ({ + type: FoldersEditorItemType.Metric as const, + uuid: m.uuid, + name: m.metric_name || '', + })), + }; + + const columnsFolder: DatasourceFolder = { + uuid: DEFAULT_COLUMNS_FOLDER_UUID, + type: FoldersEditorItemType.Folder, + name: t('Columns'), + children: columns.map(c => ({ + type: FoldersEditorItemType.Column as const, + uuid: c.uuid, + name: c.column_name || '', + })), + }; + + return [metricsFolder, columnsFolder]; +}; + +export const filterItemsBySearch = ( + searchTerm: string, + items: Array, +): Set => { + const lowerSearch = searchTerm.toLowerCase(); + const matchingIds = new Set(); + + items.forEach(item => { + const name = 'metric_name' in item ? item.metric_name : item.column_name; + if (name?.toLowerCase().includes(lowerSearch)) { + matchingIds.add(item.uuid); + } + }); + + return matchingIds; +}; + +/** + * Enrich folder children with names from metrics/columns arrays + * API returns {uuid} only, we need to add {type, name} for display + */ +const enrichFolderChildren = ( + folders: DatasourceFolder[], + metrics: Metric[], + columns: ColumnMeta[], +): DatasourceFolder[] => { + const metricMap = new Map(metrics.map(m => [m.uuid, m])); + const columnMap = new Map(columns.map(c => [c.uuid, c])); + + const enrichChildren = ( + children: (DatasourceFolder | DatasourceFolderItem)[] | undefined, + ): (DatasourceFolder | DatasourceFolderItem)[] => { + if (!children) return []; + + return children.map(child => { + // If it's a folder, recursively enrich its children + if (child.type === FoldersEditorItemType.Folder && 'children' in child) { + return { + ...child, + children: enrichChildren(child.children), + } as DatasourceFolder; + } + + // If it's a metric/column that needs enrichment (missing name or type) + const needsEnrichment = + !('name' in child) || !child.name || !('type' in child); + + if (needsEnrichment) { + // Try to find in metrics first + const metric = metricMap.get(child.uuid); + if (metric) { + return { + uuid: child.uuid, + type: FoldersEditorItemType.Metric, + name: metric.metric_name || '', + } as DatasourceFolderItem; + } + + // Then try columns + const column = columnMap.get(child.uuid); + if (column) { + return { + uuid: child.uuid, + type: FoldersEditorItemType.Column, + name: column.column_name || '', + } as DatasourceFolderItem; + } + } + + return child; + }); + }; + + return folders.map(folder => ({ + ...folder, + children: enrichChildren(folder.children), + })); +}; + +export const ensureDefaultFolders = ( + folders: DatasourceFolder[], + metrics: Metric[], + columns: ColumnMeta[], +): DatasourceFolder[] => { + if (folders.length === 0) { + return resetToDefault(metrics, columns); + } + + const enrichedFolders = enrichFolderChildren(folders, metrics, columns); + + const hasMetricsFolder = enrichedFolders.some( + f => f.uuid === DEFAULT_METRICS_FOLDER_UUID, + ); + const hasColumnsFolder = enrichedFolders.some( + 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(); + const collectAssignedIds = (folder: DatasourceFolder) => { + if (!folder.children) return; + for (const child of folder.children) { + assignedIds.add(child.uuid); + if (child.type === FoldersEditorItemType.Folder && 'children' in child) { + collectAssignedIds(child as DatasourceFolder); + } + } + }; + enrichedFolders.forEach(collectAssignedIds); + + if (!hasMetricsFolder) { + const unassignedMetrics = metrics.filter(m => !assignedIds.has(m.uuid)); + + 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 || '', + })), + }); + } + + 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 || '', + })), + }); + } + + return result; +}; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts new file mode 100644 index 00000000000..a9430da322c --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts @@ -0,0 +1,109 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Validation and constraint checking for folder operations. + * Determines what actions are allowed based on folder structure and types. + */ + +import { t } from '@apache-superset/core'; +import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; +import { + ValidationResult, + DEFAULT_METRICS_FOLDER_UUID, + DEFAULT_COLUMNS_FOLDER_UUID, +} from './constants'; + +export const validateFolders = ( + folders: DatasourceFolder[], +): ValidationResult => { + const errors: string[] = []; + const folderNames: string[] = []; + + const collectFolderNames = (items: DatasourceFolder[]) => { + items.forEach(folder => { + if (folder.name?.trim()) { + folderNames.push(folder.name.trim().toLowerCase()); + } + + if (folder.children && folder.type === 'folder') { + const childFolders = folder.children.filter( + c => c.type === 'folder', + ) as DatasourceFolder[]; + collectFolderNames(childFolders); + } + }); + }; + + const validateRecursive = (items: DatasourceFolder[]) => { + items.forEach(folder => { + const hasContent = folder.children && folder.children.length > 0; + const hasNoTitle = !folder.name?.trim(); + + if (hasContent && hasNoTitle) { + errors.push(t('Folder with content must have a name')); + } + + if (folder.uuid === DEFAULT_METRICS_FOLDER_UUID && folder.children) { + const hasColumns = folder.children.some( + child => child.type === 'column', + ); + if (hasColumns) { + errors.push(t('Metrics folder can only contain metric items')); + } + } + + if (folder.uuid === DEFAULT_COLUMNS_FOLDER_UUID && folder.children) { + const hasMetrics = folder.children.some( + child => child.type === 'metric', + ); + if (hasMetrics) { + errors.push(t('Columns folder can only contain column items')); + } + } + + if (folder.children && folder.type === 'folder') { + const childFolders = folder.children.filter( + c => c.type === 'folder', + ) as DatasourceFolder[]; + validateRecursive(childFolders); + } + }); + }; + + collectFolderNames(folders); + + const nameCounts = new Map(); + folderNames.forEach(name => { + nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + }); + nameCounts.forEach((count, name) => { + if (count > 1) { + errors.push(t('Duplicate folder name: %s', name)); + } + }); + + validateRecursive(folders); + + return { + isValid: errors.length === 0, + errors, + warnings: [], + }; +}; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts new file mode 100644 index 00000000000..a9da33039d5 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts @@ -0,0 +1,191 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef } from 'react'; +import type { VariableSizeList as List } from 'react-window'; + +// Distance from edge where auto-scroll activates (in pixels) +const SCROLL_THRESHOLD = 80; +// Scroll speed (pixels per 16ms frame at ~60fps) +const BASE_SCROLL_SPEED = 8; +// Maximum scroll speed multiplier when at the very edge +const MAX_SPEED_MULTIPLIER = 3; + +interface UseAutoScrollOptions { + listRef: React.RefObject; + containerRef: React.RefObject; + isDragging: boolean; + listHeight: number; +} + +/** + * Custom auto-scroll hook for virtualized lists during drag operations. + * This replaces dnd-kit's built-in auto-scroll which conflicts with virtualization. + * + * When the user drags near the top or bottom edge of the list container, + * this hook smoothly scrolls the react-window list. + */ +export function useAutoScroll({ + listRef, + containerRef, + isDragging, + listHeight, +}: UseAutoScrollOptions) { + // Use refs for all mutable state to avoid re-creating callbacks + const scrollStateRef = useRef({ + direction: null as 'up' | 'down' | null, + speed: 0, + mouseY: 0, + rafId: null as number | null, + lastTime: 0, + isScrolling: false, + }); + + useEffect(() => { + if (!isDragging) { + // Clean up when not dragging + const state = scrollStateRef.current; + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + state.rafId = null; + } + state.direction = null; + state.speed = 0; + return; + } + + const state = scrollStateRef.current; + + // Calculate scroll parameters based on mouse position + const updateScrollParams = () => { + const container = containerRef.current; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const relativeY = state.mouseY - containerRect.top; + + // Near top edge - scroll up + if (relativeY < SCROLL_THRESHOLD && relativeY >= 0) { + const proximity = 1 - relativeY / SCROLL_THRESHOLD; + state.direction = 'up'; + state.speed = + BASE_SCROLL_SPEED * (1 + proximity * (MAX_SPEED_MULTIPLIER - 1)); + return; + } + + // Near bottom edge - scroll down + if ( + relativeY > listHeight - SCROLL_THRESHOLD && + relativeY <= listHeight + ) { + const distanceFromBottom = listHeight - relativeY; + const proximity = 1 - distanceFromBottom / SCROLL_THRESHOLD; + state.direction = 'down'; + state.speed = + BASE_SCROLL_SPEED * (1 + proximity * (MAX_SPEED_MULTIPLIER - 1)); + return; + } + + // Not in scroll zone + state.direction = null; + state.speed = 0; + }; + + // Animation frame callback - uses time-based scrolling for consistent speed + const scrollFrame = (currentTime: number) => { + const list = listRef.current; + const outerElement = (list as any)?._outerRef; + + if (!list || !outerElement || !state.direction) { + // Restore pointer events when scrolling stops + const container = containerRef.current; + if (container && state.isScrolling) { + container.style.pointerEvents = ''; + state.isScrolling = false; + } + state.rafId = null; + return; + } + + // Disable pointer events during scroll to prevent hover recalculations + const container = containerRef.current; + if (container && !state.isScrolling) { + container.style.pointerEvents = 'none'; + state.isScrolling = true; + } + + // Calculate time delta for frame-rate independent scrolling + const deltaTime = state.lastTime + ? (currentTime - state.lastTime) / 16 + : 1; + state.lastTime = currentTime; + + const currentScroll = outerElement.scrollTop; + const maxScroll = outerElement.scrollHeight - outerElement.clientHeight; + const scrollAmount = state.speed * deltaTime; + + let newScroll = currentScroll; + if (state.direction === 'up') { + newScroll = Math.max(0, currentScroll - scrollAmount); + } else if (state.direction === 'down') { + newScroll = Math.min(maxScroll, currentScroll + scrollAmount); + } + + if (Math.abs(newScroll - currentScroll) > 0.5) { + // Use direct DOM manipulation for smoother scrolling + // react-window's scrollTo triggers re-renders which can cause stutter + outerElement.scrollTop = newScroll; + } + + // Continue animation loop + state.rafId = requestAnimationFrame(scrollFrame); + }; + + // Handle mouse move - just update position, let animation loop handle scrolling + const handleMouseMove = (event: MouseEvent) => { + state.mouseY = event.clientY; + updateScrollParams(); + + // Start animation loop if entering scroll zone + if (state.direction && state.rafId === null) { + state.lastTime = 0; + state.rafId = requestAnimationFrame(scrollFrame); + } + }; + + document.addEventListener('mousemove', handleMouseMove, { passive: true }); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + state.rafId = null; + } + // Restore pointer events on cleanup + const container = containerRef.current; + if (container && state.isScrolling) { + container.style.pointerEvents = ''; + } + state.direction = null; + state.speed = 0; + state.lastTime = 0; + state.isScrolling = false; + }; + }, [isDragging, listRef, containerRef, listHeight]); +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts new file mode 100644 index 00000000000..9da07b8a048 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts @@ -0,0 +1,663 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { t } from '@apache-superset/core'; +import { + UniqueIdentifier, + DragStartEvent, + DragMoveEvent, + DragEndEvent, + DragOverEvent, +} from '@dnd-kit/core'; +import { FoldersEditorItemType } from '../../types'; +import { + isDefaultFolder, + DEFAULT_COLUMNS_FOLDER_UUID, + DEFAULT_METRICS_FOLDER_UUID, + TreeItem as TreeItemType, + FlattenedTreeItem, + DRAG_INDENTATION_WIDTH, + MAX_DEPTH, +} from '../constants'; +import { buildTree, getProjection, serializeForAPI } from '../treeUtils'; + +interface UseDragHandlersProps { + setItems: React.Dispatch>; + computeFlattenedItems: ( + activeId: UniqueIdentifier | null, + ) => FlattenedTreeItem[]; + fullFlattenedItems: FlattenedTreeItem[]; + selectedItemIds: Set; + onChange: (folders: ReturnType) => void; + addWarningToast: (message: string) => void; +} + +export function useDragHandlers({ + setItems, + computeFlattenedItems, + fullFlattenedItems, + selectedItemIds, + onChange, + addWarningToast, +}: UseDragHandlersProps) { + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + const [dragOverlayWidth, setDragOverlayWidth] = useState(null); + const offsetLeftRef = useRef(0); + // Track current drop target - use state so virtualized items can render correctly + const [currentDropTargetId, setCurrentDropTargetId] = useState( + null, + ); + const [draggedItemIds, setDraggedItemIds] = useState>(new Set()); + + // Store the flattened items at drag start to keep them stable during drag + // This prevents react-window from re-rendering due to flattenedItems reference changes + const dragStartFlattenedItemsRef = useRef(null); + + // Compute flattened items, but during drag use the stable snapshot from drag start + // This prevents react-window from re-rendering/re-measuring when flattenedItems changes + const computedFlattenedItems = useMemo( + () => computeFlattenedItems(activeId), + [computeFlattenedItems, activeId], + ); + + // Use stable items during drag to prevent scroll jumping + // Memoize to avoid creating new array references on every render + const flattenedItems = useMemo( + () => + activeId && dragStartFlattenedItemsRef.current + ? dragStartFlattenedItemsRef.current + : computedFlattenedItems, + [activeId, computedFlattenedItems], + ); + + const flattenedItemsIndexMap = useMemo(() => { + const map = new Map(); + flattenedItems.forEach((item, index) => { + map.set(item.uuid, index); + }); + return map; + }, [flattenedItems]); + + // Shared lookup maps for O(1) access - used by handleDragEnd and forbiddenDropFolderIds + const fullItemsByUuid = useMemo(() => { + const map = new Map(); + fullFlattenedItems.forEach(item => { + map.set(item.uuid, item); + }); + return map; + }, [fullFlattenedItems]); + + const fullItemsIndexMap = useMemo(() => { + const map = new Map(); + fullFlattenedItems.forEach((item, index) => { + map.set(item.uuid, index); + }); + return map; + }, [fullFlattenedItems]); + + const childrenByParentId = useMemo(() => { + const map = new Map(); + fullFlattenedItems.forEach(item => { + if (item.parentId) { + const children = map.get(item.parentId) ?? []; + children.push(item); + map.set(item.parentId, children); + } + }); + return map; + }, [fullFlattenedItems]); + + // Shared helper to calculate max folder descendant depth + // Only counts folder depths, not items (columns/metrics) + const getMaxFolderDescendantDepth = useCallback( + (parentId: string, baseDepth: number): number => { + const children = childrenByParentId.get(parentId); + if (!children || children.length === 0) { + return baseDepth; + } + let maxDepth = baseDepth; + for (const child of children) { + if (child.type === FoldersEditorItemType.Folder) { + maxDepth = Math.max(maxDepth, child.depth); + maxDepth = Math.max( + maxDepth, + getMaxFolderDescendantDepth(child.uuid, child.depth), + ); + } + } + return maxDepth; + }, + [childrenByParentId], + ); + + const resetDragState = useCallback(() => { + setActiveId(null); + setOverId(null); + offsetLeftRef.current = 0; + setCurrentDropTargetId(null); + setDraggedItemIds(new Set()); + setDragOverlayWidth(null); + // Clear the stable snapshot so next render uses fresh computed items + dragStartFlattenedItemsRef.current = null; + }, []); + + const handleDragStart = ({ active }: DragStartEvent) => { + // Capture the current flattened items BEFORE setting activeId + // This ensures the list stays stable during the entire drag operation + dragStartFlattenedItemsRef.current = computeFlattenedItems(null); + + setActiveId(active.id); + + const element = active.rect.current.initial; + if (element) { + setDragOverlayWidth(element.width); + } + + if (selectedItemIds.has(active.id as string)) { + setDraggedItemIds(new Set(selectedItemIds)); + } else { + setDraggedItemIds(new Set([active.id as string])); + } + }; + + const handleDragMove = useCallback( + ({ delta }: DragMoveEvent) => { + offsetLeftRef.current = delta.x; + + if (activeId && overId) { + if (typeof overId === 'string' && overId.endsWith('-empty')) { + const folderId = overId.replace('-empty', ''); + setCurrentDropTargetId(folderId); + return; + } + + const projection = getProjection( + flattenedItems, + activeId, + overId, + delta.x, + DRAG_INDENTATION_WIDTH, + flattenedItemsIndexMap, + ); + const newParentId = projection?.parentId ?? null; + setCurrentDropTargetId(newParentId); + } + }, + [activeId, overId, flattenedItems, flattenedItemsIndexMap], + ); + + const handleDragOver = useCallback( + ({ over }: DragOverEvent) => { + setOverId(over?.id ?? null); + + if (activeId && over) { + if (typeof over.id === 'string' && over.id.endsWith('-empty')) { + const folderId = over.id.replace('-empty', ''); + setCurrentDropTargetId(folderId); + return; + } + + const projection = getProjection( + flattenedItems, + activeId, + over.id, + offsetLeftRef.current, + DRAG_INDENTATION_WIDTH, + flattenedItemsIndexMap, + ); + const newParentId = projection?.parentId ?? null; + setCurrentDropTargetId(newParentId); + } else { + setCurrentDropTargetId(null); + } + }, + [activeId, flattenedItems, flattenedItemsIndexMap], + ); + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + const itemsBeingDragged = Array.from(draggedItemIds); + const finalOffsetLeft = offsetLeftRef.current; + resetDragState(); + + if (!over || itemsBeingDragged.length === 0) { + return; + } + + let targetOverId = over.id; + let isEmptyDrop = false; + if (typeof over.id === 'string' && over.id.endsWith('-empty')) { + targetOverId = over.id.replace('-empty', ''); + isEmptyDrop = true; + + if (itemsBeingDragged.includes(targetOverId as string)) { + return; + } + } + + const activeIndex = fullItemsIndexMap.get(active.id as string) ?? -1; + const overIndex = fullItemsIndexMap.get(targetOverId as string) ?? -1; + + if (activeIndex === -1 || overIndex === -1) { + return; + } + + // Use Set for O(1) lookup instead of Array.includes + const itemsBeingDraggedSet = new Set(itemsBeingDragged); + const draggedItems = fullFlattenedItems.filter((item: FlattenedTreeItem) => + itemsBeingDraggedSet.has(item.uuid), + ); + + let projectedPosition = getProjection( + flattenedItems, + active.id, + targetOverId, + finalOffsetLeft, + DRAG_INDENTATION_WIDTH, + flattenedItemsIndexMap, + ); + + if (isEmptyDrop) { + const targetFolder = fullFlattenedItems[overIndex]; + projectedPosition = { + depth: targetFolder.depth + 1, + maxDepth: targetFolder.depth + 1, + minDepth: targetFolder.depth + 1, + parentId: targetOverId as string, + }; + } + + const activeItem = fullFlattenedItems[activeIndex]; + if (active.id === targetOverId) { + const newParentId = projectedPosition?.parentId ?? null; + const currentParentId = activeItem.parentId; + if (newParentId === currentParentId) { + return; + } + } + + // Single pass to gather info about dragged items + let hasNonFolderItems = false; + let hasDraggedFolder = false; + let hasDraggedDefaultFolder = false; + for (const item of draggedItems) { + if (item.type === FoldersEditorItemType.Folder) { + hasDraggedFolder = true; + if (isDefaultFolder(item.uuid)) { + hasDraggedDefaultFolder = true; + } + } else { + hasNonFolderItems = true; + } + } + + if (hasNonFolderItems) { + if (!projectedPosition || !projectedPosition.parentId) { + return; + } + } + + if (projectedPosition && projectedPosition.parentId) { + const targetFolder = fullItemsByUuid.get(projectedPosition.parentId); + + if (targetFolder && isDefaultFolder(targetFolder.uuid)) { + const isDefaultMetricsFolder = + targetFolder.uuid === DEFAULT_METRICS_FOLDER_UUID; + const isDefaultColumnsFolder = + targetFolder.uuid === DEFAULT_COLUMNS_FOLDER_UUID; + + for (const draggedItem of draggedItems) { + if (draggedItem.type === FoldersEditorItemType.Folder) { + addWarningToast(t('Cannot nest folders in default folders')); + return; + } + if ( + isDefaultMetricsFolder && + draggedItem.type === FoldersEditorItemType.Column + ) { + addWarningToast(t('This folder only accepts metrics')); + return; + } + if ( + isDefaultColumnsFolder && + draggedItem.type === FoldersEditorItemType.Metric + ) { + addWarningToast(t('This folder only accepts columns')); + return; + } + } + } + } + + if (hasDraggedDefaultFolder && projectedPosition?.parentId) { + addWarningToast(t('Default folders cannot be nested')); + return; + } + + // Check max depth for folders + if (hasDraggedFolder && projectedPosition) { + for (const draggedItem of draggedItems) { + if (draggedItem.type === FoldersEditorItemType.Folder) { + const currentDepth = draggedItem.depth; + const maxFolderDescendantDepth = getMaxFolderDescendantDepth( + draggedItem.uuid, + currentDepth, + ); + const descendantDepthOffset = maxFolderDescendantDepth - currentDepth; + const newDepth = projectedPosition.depth; + const newMaxDescendantDepth = newDepth + descendantDepthOffset; + + // MAX_DEPTH is 3, meaning we allow depths 0, 1, 2 (3 levels total) + if (newMaxDescendantDepth >= MAX_DEPTH) { + addWarningToast(t('Maximum folder nesting depth reached')); + return; + } + } + } + } + + let newItems = fullFlattenedItems; + + if (projectedPosition) { + const depthChange = projectedPosition.depth - activeItem.depth; + + const itemsToUpdate = new Map< + string, + { depth: number; parentId: string | null | undefined } + >(); + + draggedItems.forEach((item: FlattenedTreeItem) => { + if (item.uuid === active.id) { + itemsToUpdate.set(item.uuid, { + depth: projectedPosition.depth, + parentId: projectedPosition.parentId, + }); + } else { + itemsToUpdate.set(item.uuid, { + depth: item.depth + depthChange, + parentId: projectedPosition.parentId, + }); + } + }); + + const collectDescendants = ( + parentId: string, + parentDepthChange: number, + ) => { + const children = childrenByParentId.get(parentId); + if (!children) return; + for (const item of children) { + if (!itemsToUpdate.has(item.uuid)) { + itemsToUpdate.set(item.uuid, { + depth: item.depth + parentDepthChange, + parentId: undefined, + }); + if (item.type === FoldersEditorItemType.Folder) { + collectDescendants(item.uuid, parentDepthChange); + } + } + } + }; + + draggedItems.forEach((item: FlattenedTreeItem) => { + if (item.type === FoldersEditorItemType.Folder) { + collectDescendants(item.uuid, depthChange); + } + }); + + newItems = fullFlattenedItems.map((item: FlattenedTreeItem) => { + const update = itemsToUpdate.get(item.uuid); + if (update) { + const newParentId = + update.parentId === undefined ? item.parentId : update.parentId; + return { + ...item, + depth: update.depth, + parentId: newParentId, + }; + } + return item; + }); + } + + const itemsToMoveIds = new Set(itemsBeingDragged); + + const collectDescendantIds = (parentId: string) => { + const children = childrenByParentId.get(parentId); + if (!children) return; + for (const item of children) { + if (!itemsToMoveIds.has(item.uuid)) { + itemsToMoveIds.add(item.uuid); + if (item.type === FoldersEditorItemType.Folder) { + collectDescendantIds(item.uuid); + } + } + } + }; + + draggedItems.forEach((item: FlattenedTreeItem) => { + if (item.type === FoldersEditorItemType.Folder) { + collectDescendantIds(item.uuid); + } + }); + + // Indices are already in ascending order since we iterate fullFlattenedItems sequentially + const itemsToMoveIndices: number[] = []; + fullFlattenedItems.forEach((item: FlattenedTreeItem, idx: number) => { + if (itemsToMoveIds.has(item.uuid)) { + itemsToMoveIndices.push(idx); + } + }); + + const subtree = itemsToMoveIndices.map(idx => newItems[idx]); + const itemsToMoveIndicesSet = new Set(itemsToMoveIndices); + const remaining = newItems.filter( + (_: FlattenedTreeItem, idx: number) => !itemsToMoveIndicesSet.has(idx), + ); + + let insertionIndex = 0; + + if (projectedPosition && projectedPosition.parentId) { + const parentIndex = remaining.findIndex( + item => item.uuid === projectedPosition.parentId, + ); + + if (parentIndex !== -1) { + if (isEmptyDrop) { + insertionIndex = parentIndex + 1; + } else { + const overItemInRemaining = remaining.findIndex( + item => item.uuid === targetOverId, + ); + + if (overItemInRemaining !== -1) { + const overItem = remaining[overItemInRemaining]; + + if (overItem.parentId === projectedPosition.parentId) { + if (activeIndex < overIndex) { + insertionIndex = overItemInRemaining + 1; + } else { + insertionIndex = overItemInRemaining; + } + } else if (projectedPosition.depth > overItem.depth) { + insertionIndex = overItemInRemaining + 1; + } else { + insertionIndex = overItemInRemaining + 1; + } + } else { + insertionIndex = parentIndex + 1; + } + } + } + } else { + let adjustedOverIndex = overIndex; + itemsToMoveIndices.forEach((idx: number) => { + if (idx < overIndex) { + adjustedOverIndex -= 1; + } + }); + insertionIndex = adjustedOverIndex; + } + + const sortedItems = [ + ...remaining.slice(0, insertionIndex), + ...subtree, + ...remaining.slice(insertionIndex), + ]; + + // Safety check: verify all items are preserved after sorting + if (sortedItems.length !== fullFlattenedItems.length) { + // If items were lost, don't apply the change + return; + } + + const newTree = buildTree(sortedItems); + setItems(newTree); + onChange(serializeForAPI(newTree)); + }; + + const handleDragCancel = () => { + resetDragState(); + }; + + const dragOverlayItems = useMemo(() => { + if (!activeId || draggedItemIds.size === 0) return []; + + const draggedItems = fullFlattenedItems.filter((item: FlattenedTreeItem) => + draggedItemIds.has(item.uuid), + ); + + return draggedItems.slice(0, 3); + }, [activeId, draggedItemIds, fullFlattenedItems]); + + const forbiddenDropFolderIds = useMemo(() => { + const forbidden = new Set(); + if (draggedItemIds.size === 0) { + return forbidden; + } + + const draggedTypes = new Set(); + let hasDraggedDefaultFolder = false; + let maxDraggedFolderDescendantOffset = 0; + + draggedItemIds.forEach((id: string) => { + const item = fullItemsByUuid.get(id); + if (item) { + draggedTypes.add(item.type); + if ( + item.type === FoldersEditorItemType.Folder && + isDefaultFolder(item.uuid) + ) { + hasDraggedDefaultFolder = true; + } + // Track the deepest folder descendant offset for dragged folders + if (item.type === FoldersEditorItemType.Folder) { + const maxDescendantDepth = getMaxFolderDescendantDepth( + item.uuid, + item.depth, + ); + const descendantOffset = maxDescendantDepth - item.depth; + maxDraggedFolderDescendantOffset = Math.max( + maxDraggedFolderDescendantOffset, + descendantOffset, + ); + } + } + }); + + const hasDraggedFolder = draggedTypes.has(FoldersEditorItemType.Folder); + + fullFlattenedItems.forEach((item: FlattenedTreeItem) => { + if (item.type !== FoldersEditorItemType.Folder) { + return; + } + + const itemIsDefaultFolder = isDefaultFolder(item.uuid); + + if (hasDraggedDefaultFolder && !itemIsDefaultFolder) { + forbidden.add(item.uuid); + return; + } + + const isDefaultMetricsFolder = + item.uuid === DEFAULT_METRICS_FOLDER_UUID && itemIsDefaultFolder; + const isDefaultColumnsFolder = + item.uuid === DEFAULT_COLUMNS_FOLDER_UUID && itemIsDefaultFolder; + + if ( + (isDefaultMetricsFolder || isDefaultColumnsFolder) && + hasDraggedFolder + ) { + forbidden.add(item.uuid); + return; + } + + if ( + isDefaultMetricsFolder && + draggedTypes.has(FoldersEditorItemType.Column) + ) { + forbidden.add(item.uuid); + return; + } + if ( + isDefaultColumnsFolder && + draggedTypes.has(FoldersEditorItemType.Metric) + ) { + forbidden.add(item.uuid); + return; + } + + // Check max depth for folders: dropping into this folder would put the item at depth + 1 + // If that would exceed MAX_DEPTH - 1 (accounting for descendants), forbid it + if (hasDraggedFolder) { + const newFolderDepth = item.depth + 1; + const newMaxDescendantDepth = + newFolderDepth + maxDraggedFolderDescendantOffset; + if (newMaxDescendantDepth >= MAX_DEPTH) { + forbidden.add(item.uuid); + } + } + }); + + return forbidden; + }, [ + draggedItemIds, + fullFlattenedItems, + fullItemsByUuid, + getMaxFolderDescendantDepth, + ]); + + return { + isDragging: activeId !== null, + activeId, + draggedItemIds, + dragOverlayWidth, + flattenedItems, + dragOverlayItems, + forbiddenDropFolderIds, + currentDropTargetId, + fullItemsByUuid, + handleDragStart, + handleDragMove, + handleDragOver, + handleDragEnd, + handleDragCancel, + }; +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts new file mode 100644 index 00000000000..e95a4215f05 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts @@ -0,0 +1,81 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useRef } from 'react'; + +export interface HeightCache { + /** Get the cached height for an item, or undefined if not cached */ + getHeight: (id: string) => number | undefined; + /** Set the cached height for an item */ + setHeight: (id: string, height: number) => void; + /** Check if a height is cached for an item */ + hasHeight: (id: string) => boolean; + /** Clear all cached heights */ + clearCache: () => void; + /** Get the current version number (increments on cache changes) */ + getVersion: () => number; +} + +/** + * Hook that provides a cache for dynamically measured item heights. + * Used for items like EmptyState folders where height cannot be pre-calculated. + * + * The cache uses a ref to avoid re-renders when heights are updated. + * Call getVersion() to get a version number that changes when cache updates, + * which can be used to trigger re-renders when needed. + */ +export function useHeightCache(): HeightCache { + const cacheRef = useRef>(new Map()); + const versionRef = useRef(0); + + const getHeight = useCallback( + (id: string): number | undefined => cacheRef.current.get(id), + [], + ); + + const setHeight = useCallback((id: string, height: number): void => { + const currentHeight = cacheRef.current.get(id); + if (currentHeight !== height) { + cacheRef.current.set(id, height); + versionRef.current += 1; + } + }, []); + + const hasHeight = useCallback( + (id: string): boolean => cacheRef.current.has(id), + [], + ); + + const clearCache = useCallback((): void => { + if (cacheRef.current.size > 0) { + cacheRef.current.clear(); + versionRef.current += 1; + } + }, []); + + const getVersion = useCallback((): number => versionRef.current, []); + + return { + getHeight, + setHeight, + hasHeight, + clearCache, + getVersion, + }; +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts new file mode 100644 index 00000000000..4654637b292 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts @@ -0,0 +1,89 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo } from 'react'; +import { useTheme, SupersetTheme } from '@apache-superset/core/ui'; +import { + FOLDER_INDENTATION_WIDTH, + ITEM_INDENTATION_WIDTH, +} from '../TreeItem.styles'; + +export interface ItemHeights { + /** Height of a regular item (metric/column) including margins */ + regularItem: number; + /** Height of a folder header including margins */ + folderHeader: number; + /** Height of a visible separator (colored line between top-level sections) */ + separatorVisible: number; + /** Height of a transparent separator (spacing between nested items) */ + separatorTransparent: number; + /** Base height estimate for empty folder with EmptyState */ + emptyFolderBase: number; + /** Indentation width per folder depth level */ + folderIndentation: number; + /** Indentation width for items */ + itemIndentation: number; +} + +/** + * Calculates item heights based on theme tokens. + * These heights include spacing since react-window uses absolute positioning + * where CSS margins don't collapse. + * + * The spacing is built into the height calculation, NOT the CSS margins, + * to avoid double-spacing issues with absolute positioning. + */ +function calculateItemHeights(theme: SupersetTheme): ItemHeights { + // Regular item height - just the row height, minimal spacing + // The OptionControlContainer sets the actual content height + const regularItem = 32; + + // Folder header - base height + vertical padding (for taller highlight) + bottom spacing + const folderHeader = 32 + theme.paddingSM + theme.marginXS; + + // Separator visible: 1px line + vertical margins (marginSM above and below) + const separatorVisible = 1 + theme.marginSM * 2; + + // Separator transparent: 1px line + small vertical margin + const separatorTransparent = 1 + theme.marginXS * 2; + + // Empty folder with EmptyState: measured from actual rendering (~236px) + // EmptyFolderDropZone padding (24*2) + EmptyState content (~188px) + const emptyFolderBase = 240; + + return { + regularItem, + folderHeader, + separatorVisible, + separatorTransparent, + emptyFolderBase, + folderIndentation: FOLDER_INDENTATION_WIDTH, + itemIndentation: ITEM_INDENTATION_WIDTH, + }; +} + +/** + * Hook that provides item heights calculated from the current theme. + * Use this for virtualized list height calculations. + */ +export function useItemHeights(): ItemHeights { + const theme = useTheme(); + + return useMemo(() => calculateItemHeights(theme), [theme]); +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx new file mode 100644 index 00000000000..7325569c50c --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx @@ -0,0 +1,467 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { debounce } from 'lodash'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + UniqueIdentifier, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { FoldersEditorItemType } from '../types'; +import { TreeItem as TreeItemType } from './constants'; +import { + flattenTree, + buildTree, + removeChildrenOf, + serializeForAPI, +} from './treeUtils'; +import { + createFolder, + resetToDefault, + ensureDefaultFolders, + filterItemsBySearch, +} from './folderOperations'; +import { + pointerSensorOptions, + measuringConfig, + autoScrollConfig, +} from './sensors'; +import { FoldersContainer, FoldersContent } from './styles'; +import { FoldersEditorProps } from './types'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { useDragHandlers } from './hooks/useDragHandlers'; +import { useItemHeights } from './hooks/useItemHeights'; +import { useHeightCache } from './hooks/useHeightCache'; +import { + FoldersToolbarComponent, + ResetConfirmModal, + DragOverlayContent, +} from './components'; +import { VirtualizedTreeList } from './VirtualizedTreeList'; + +export default function FoldersEditor({ + folders: initialFolders, + metrics, + columns, + onChange, +}: FoldersEditorProps) { + const { addWarningToast } = useToasts(); + const itemHeights = useItemHeights(); + const heightCache = useHeightCache(); + + const [items, setItems] = useState(() => { + const ensured = ensureDefaultFolders(initialFolders, metrics, columns); + return ensured; + }); + + const [selectedItemIds, setSelectedItemIds] = useState>( + new Set(), + ); + const lastSelectedItemIdRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(''); + const [collapsedIds, setCollapsedIds] = useState>(new Set()); + const [editingFolderId, setEditingFolderId] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + const sensors = useSensors(useSensor(PointerSensor, pointerSensorOptions)); + + const fullFlattenedItems = useMemo(() => flattenTree(items), [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); + } + } + return result; + }, [fullFlattenedItems, collapsedIds]); + + const computeFlattenedItems = useCallback( + (activeId: UniqueIdentifier | null) => + removeChildrenOf( + fullFlattenedItems, + activeId != null + ? [activeId, ...collapsedFolderIds] + : collapsedFolderIds, + ), + [fullFlattenedItems, collapsedFolderIds], + ); + + const visibleItemIds = useMemo(() => { + if (!searchTerm) { + const allIds = new Set(); + 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], + ); + const columnsMap = useMemo( + () => new Map(columns.map(c => [c.uuid, c])), + [columns], + ); + + const { + isDragging, + activeId, + dragOverlayWidth, + flattenedItems, + dragOverlayItems, + forbiddenDropFolderIds, + currentDropTargetId, + fullItemsByUuid, + handleDragStart, + handleDragMove, + handleDragOver, + handleDragEnd, + handleDragCancel, + } = useDragHandlers({ + setItems, + computeFlattenedItems, + fullFlattenedItems, + selectedItemIds, + onChange, + addWarningToast, + }); + + const debouncedSearch = useCallback( + debounce((term: string) => { + setSearchTerm(term); + }, 300), + [], + ); + + const handleSearch = (e: React.ChangeEvent) => { + debouncedSearch(e.target.value); + }; + + const handleAddFolder = () => { + const newFolder = createFolder(''); + const updatedItems = [newFolder, ...items]; + setItems(updatedItems); + setEditingFolderId(newFolder.uuid); + onChange(serializeForAPI(updatedItems)); + }; + + const allVisibleSelected = useMemo(() => { + const selectableItems = Array.from(visibleItemIds).filter(id => { + const item = fullItemsByUuid.get(id); + return item && item.type !== FoldersEditorItemType.Folder; + }); + return ( + selectableItems.length > 0 && + selectableItems.every(id => selectedItemIds.has(id)) + ); + }, [fullItemsByUuid, visibleItemIds, selectedItemIds]); + + const handleSelectAll = useCallback(() => { + const itemsToSelect = new Set( + Array.from(visibleItemIds).filter(id => { + const item = fullItemsByUuid.get(id); + return item && item.type !== FoldersEditorItemType.Folder; + }), + ); + + if (allVisibleSelected) { + setSelectedItemIds(new Set()); + } else { + setSelectedItemIds(itemsToSelect); + } + }, [visibleItemIds, fullItemsByUuid, allVisibleSelected]); + + const handleResetToDefault = () => { + setShowResetConfirm(true); + }; + + const handleConfirmReset = () => { + const resetFolders = resetToDefault(metrics, columns); + setItems(resetFolders); + setSelectedItemIds(new Set()); + setEditingFolderId(null); + setShowResetConfirm(false); + onChange(serializeForAPI(resetFolders)); + }; + + const handleCancelReset = () => { + setShowResetConfirm(false); + }; + + const handleToggleCollapse = useCallback((folderId: string) => { + setCollapsedIds(prev => { + const newSet = new Set(prev); + if (newSet.has(folderId)) { + newSet.delete(folderId); + } else { + newSet.add(folderId); + } + return newSet; + }); + }, []); + + const handleSelect = useCallback( + (itemId: string, selected: boolean, shiftKey?: boolean) => { + // Capture ref value before setState to avoid timing issues with React 18 batching + const lastSelectedId = lastSelectedItemIdRef.current; + + // Update ref immediately for next interaction + if (selected) { + lastSelectedItemIdRef.current = itemId; + } + + setSelectedItemIds(prev => { + const newSet = new Set(prev); + + // Range selection when shift is held and we have a previous selection + if (shiftKey && selected && lastSelectedId) { + const selectableItems = flattenedItems.filter( + item => item.type !== FoldersEditorItemType.Folder, + ); + + const currentIndex = selectableItems.findIndex( + item => item.uuid === itemId, + ); + const lastIndex = selectableItems.findIndex( + item => item.uuid === lastSelectedId, + ); + + if (currentIndex !== -1 && lastIndex !== -1) { + const startIndex = Math.min(currentIndex, lastIndex); + const endIndex = Math.max(currentIndex, lastIndex); + + for (let i = startIndex; i <= endIndex; i += 1) { + newSet.add(selectableItems[i].uuid); + } + } + } else if (selected) { + newSet.add(itemId); + } else { + newSet.delete(itemId); + } + + return newSet; + }); + }, + [flattenedItems], + ); + + const handleStartEdit = useCallback((folderId: string) => { + setEditingFolderId(folderId); + }, []); + + const handleFinishEdit = useCallback( + (folderId: string, newName: string) => { + if (newName.trim() && newName !== folderId) { + setItems(prevItems => { + const flatItems = flattenTree(prevItems); + const updatedItems = flatItems.map(item => { + if (item.uuid === folderId) { + return { ...item, name: newName }; + } + return item; + }); + const newTree = buildTree(updatedItems); + onChange(serializeForAPI(newTree)); + return newTree; + }); + } + setEditingFolderId(null); + }, + [onChange], + ); + + const lastChildIds = useMemo(() => { + const lastChildren = new Set(); + const childrenByParent = new Map(); + + flattenedItems.forEach(item => { + const parentKey = item.parentId; + if (!childrenByParent.has(parentKey)) { + childrenByParent.set(parentKey, []); + } + childrenByParent.get(parentKey)!.push(item.uuid); + }); + + childrenByParent.forEach(children => { + if (children.length > 0) { + lastChildren.add(children[children.length - 1]); + } + }); + + return lastChildren; + }, [flattenedItems]); + + const itemSeparatorInfo = useMemo(() => { + const separators = new Map(); + + flattenedItems.forEach((item, index) => { + if (item.type === FoldersEditorItemType.Folder) { + return; + } + + if (!lastChildIds.has(item.uuid)) { + return; + } + + const nextItem = flattenedItems[index + 1]; + if (!nextItem) { + return; + } + + // Case 1: Next item is a top-level folder (depth 0) + // This means current item is the last descendant of the previous top-level folder + // -> Full-width colored separator + if ( + nextItem.type === FoldersEditorItemType.Folder && + nextItem.depth === 0 + ) { + separators.set(item.uuid, 'visible'); + return; + } + + // Case 2: Last item of a nested folder followed by a sibling item (not a folder) + // -> Transparent separator for spacing + if ( + item.depth > 1 && + nextItem.depth < item.depth && + nextItem.type !== FoldersEditorItemType.Folder + ) { + separators.set(item.uuid, 'transparent'); + } + + // Case 3: Nested folder followed by another nested folder -> no separator + }); + + return separators; + }, [flattenedItems, lastChildIds]); + + const sortableItemIds = useMemo( + () => flattenedItems.map(({ uuid }) => uuid), + [flattenedItems], + ); + + const folderChildCounts = useMemo(() => { + const counts = new Map(); + // Initialize all folders with 0 + for (const item of flattenedItems) { + if (item.type === FoldersEditorItemType.Folder) { + counts.set(item.uuid, 0); + } + } + // Single pass: count children by parentId + for (const item of flattenedItems) { + if (item.parentId && counts.has(item.parentId)) { + counts.set(item.parentId, counts.get(item.parentId)! + 1); + } + } + return counts; + }, [flattenedItems]); + + return ( + + + + + + + + {({ height, width }) => ( + + )} + + + + + + + + + + ); +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts new file mode 100644 index 00000000000..3c8cdc467e9 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + PointerSensorOptions, + MeasuringConfiguration, + MeasuringStrategy, +} from '@dnd-kit/core'; + +export const pointerSensorOptions: PointerSensorOptions = { + activationConstraint: { + distance: 8, + }, +}; + +// Use BeforeDragging strategy to measure items once at drag start rather than continuously. +// This is critical for virtualized lists where items get unmounted during scroll. +// MeasuringStrategy.Always causes issues because dnd-kit loses track of items +// that are unmounted by react-window during auto-scroll. +export const measuringConfig: MeasuringConfiguration = { + droppable: { + strategy: MeasuringStrategy.BeforeDragging, + }, +}; + +// Disable auto-scroll because it conflicts with virtualization. +// When auto-scroll moves the viewport, react-window unmounts items that scroll out of view, +// which causes dnd-kit to lose track of the dragged item and reset the drag operation. +export const autoScrollConfig = { + enabled: false, +}; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx new file mode 100644 index 00000000000..b24fc658d2c --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { styled, css } from '@apache-superset/core/ui'; + +export const FoldersContainer = styled.div` + display: flex; + flex-direction: column; + position: relative; + height: 70vh; + gap: ${({ theme }) => theme.paddingMD}px; +`; + +export const FoldersToolbar = styled.div` + ${({ theme }) => ` + position: sticky; + top: -${theme.margin}px; // offsets tabs component bottom margin + z-index: 10; + background: ${theme.colorBgContainer}; + padding-top: ${theme.paddingMD}px; + display: flex; + flex-direction: column; + gap: ${theme.paddingLG}px; + `} +`; + +export const FoldersSearch = styled.div` + width: 100%; + + .ant-input-prefix { + color: ${({ theme }) => theme.colorIcon}; + } +`; + +export const FoldersActions = styled.div` + ${({ theme }) => ` + display: flex; + gap: ${theme.paddingSM}px; + `} +`; + +export const FoldersContent = styled.div` + flex: 1; + min-height: 0; + overflow: hidden; +`; + +const STACK_OFFSET_X = 4; +const STACK_OFFSET_Y = 14; + +export const DragOverlayStack = styled.div<{ width?: number }>` + position: relative; + width: ${({ width }) => (width ? `${width}px` : '100%')}; + will-change: transform; +`; + +export const DragOverlayItem = styled.div<{ + stackIndex: number; + totalItems: number; +}>` + ${({ stackIndex, totalItems }) => { + const opacities = [1, 0.8, 0.6]; + const opacity = opacities[stackIndex] ?? 0.6; + + return css` + position: ${stackIndex === 0 ? 'relative' : 'absolute'}; + top: ${stackIndex * STACK_OFFSET_Y}px; + left: ${stackIndex * STACK_OFFSET_X}px; + right: ${stackIndex === 0 ? 0 : -stackIndex * STACK_OFFSET_X}px; + z-index: ${totalItems - stackIndex}; + opacity: ${opacity}; + pointer-events: none; + `; + }} +`; diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts new file mode 100644 index 00000000000..c6fdfa444b7 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.test.ts @@ -0,0 +1,669 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + TreeItem, + FlattenedTreeItem, + DRAG_INDENTATION_WIDTH, +} from './constants'; +import { + flattenTree, + buildTree, + removeChildrenOf, + serializeForAPI, + getProjection, +} from './treeUtils'; +import { FoldersEditorItemType } from '../types'; + +const createMetricItem = (uuid: string, name: string): TreeItem => ({ + uuid, + type: FoldersEditorItemType.Metric, + name, +}); + +const createColumnItem = (uuid: string, name: string): TreeItem => ({ + uuid, + type: FoldersEditorItemType.Column, + name, +}); + +const createFolderItem = ( + uuid: string, + name: string, + children: TreeItem[] = [], +): TreeItem => ({ + uuid, + type: FoldersEditorItemType.Folder, + name, + children, +}); + +test('flattenTree converts nested tree to flat array', () => { + const tree: TreeItem[] = [ + createFolderItem('folder1', 'Folder 1', [ + createMetricItem('metric1', 'Metric 1'), + createMetricItem('metric2', 'Metric 2'), + ]), + createFolderItem('folder2', 'Folder 2', [ + createColumnItem('column1', 'Column 1'), + ]), + ]; + + const flattened = flattenTree(tree); + + expect(flattened).toHaveLength(5); + expect(flattened[0].uuid).toBe('folder1'); + expect(flattened[0].depth).toBe(0); + expect(flattened[0].parentId).toBeNull(); + expect(flattened[1].uuid).toBe('metric1'); + expect(flattened[1].depth).toBe(1); + expect(flattened[1].parentId).toBe('folder1'); + expect(flattened[2].uuid).toBe('metric2'); + expect(flattened[2].depth).toBe(1); + expect(flattened[3].uuid).toBe('folder2'); + expect(flattened[3].depth).toBe(0); + expect(flattened[4].uuid).toBe('column1'); + expect(flattened[4].depth).toBe(1); + expect(flattened[4].parentId).toBe('folder2'); +}); + +test('flattenTree handles nested folders', () => { + const tree: TreeItem[] = [ + createFolderItem('parent', 'Parent', [ + createFolderItem('child', 'Child', [ + createMetricItem('metric1', 'Metric 1'), + ]), + ]), + ]; + + const flattened = flattenTree(tree); + + expect(flattened).toHaveLength(3); + expect(flattened[0].depth).toBe(0); + expect(flattened[1].depth).toBe(1); + expect(flattened[1].parentId).toBe('parent'); + expect(flattened[2].depth).toBe(2); + expect(flattened[2].parentId).toBe('child'); +}); + +test('flattenTree handles empty tree', () => { + const flattened = flattenTree([]); + expect(flattened).toHaveLength(0); +}); + +test('buildTree reconstructs tree from flattened items', () => { + const flatItems: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'folder1', + depth: 1, + index: 0, + }, + { + uuid: 'folder2', + type: FoldersEditorItemType.Folder, + name: 'Folder 2', + parentId: null, + depth: 0, + index: 1, + children: [], + }, + ]; + + const tree = buildTree(flatItems); + + expect(tree).toHaveLength(2); + expect(tree[0].uuid).toBe('folder1'); + expect((tree[0] as any).children).toHaveLength(1); + expect((tree[0] as any).children[0].uuid).toBe('metric1'); + expect(tree[1].uuid).toBe('folder2'); +}); + +test('buildTree handles orphan items by placing them at root', () => { + const flatItems: FlattenedTreeItem[] = [ + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'nonexistent-parent', + depth: 1, + index: 0, + }, + ]; + + const tree = buildTree(flatItems); + + expect(tree).toHaveLength(1); + expect(tree[0].uuid).toBe('metric1'); +}); + +test('removeChildrenOf filters out children of specified parents', () => { + const items: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + children: [ + { uuid: 'metric1', type: FoldersEditorItemType.Metric, name: 'M' }, + ], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'folder1', + depth: 1, + index: 0, + }, + { + uuid: 'folder2', + type: FoldersEditorItemType.Folder, + name: 'Folder 2', + parentId: null, + depth: 0, + index: 1, + children: [], + }, + ]; + + const filtered = removeChildrenOf(items, ['folder1']); + + expect(filtered).toHaveLength(2); + expect(filtered.find(i => i.uuid === 'metric1')).toBeUndefined(); +}); + +test('removeChildrenOf recursively removes nested children when parent has children property', () => { + // Note: removeChildrenOf only recurses into children that are excluded AND have children property set + const items: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + children: [ + { uuid: 'folder2', type: FoldersEditorItemType.Folder, name: 'F2' }, + ], + }, + { + uuid: 'folder2', + type: FoldersEditorItemType.Folder, + name: 'Folder 2', + parentId: 'folder1', + depth: 1, + index: 0, + children: [ + { uuid: 'metric1', type: FoldersEditorItemType.Metric, name: 'M' }, + ], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'folder2', + depth: 2, + index: 0, + }, + ]; + + const filtered = removeChildrenOf(items, ['folder1']); + + expect(filtered).toHaveLength(1); + expect(filtered[0].uuid).toBe('folder1'); +}); + +test('serializeForAPI excludes empty folders', () => { + const tree: TreeItem[] = [ + createFolderItem('folder1', 'Folder 1', []), + createFolderItem('folder2', 'Folder 2', [ + createMetricItem('metric1', 'Metric 1'), + ]), + ]; + + const serialized = serializeForAPI(tree); + + expect(serialized).toHaveLength(1); + expect(serialized[0].uuid).toBe('folder2'); +}); + +test('serializeForAPI includes only uuid and type for leaf items', () => { + const tree: TreeItem[] = [ + createFolderItem('folder1', 'Folder 1', [ + createMetricItem('metric1', 'Metric 1'), + ]), + ]; + + const serialized = serializeForAPI(tree); + + expect(serialized[0].children).toHaveLength(1); + expect(serialized[0].children![0]).toEqual({ + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + }); +}); + +test('serializeForAPI preserves nested folder structure', () => { + const tree: TreeItem[] = [ + createFolderItem('parent', 'Parent', [ + createFolderItem('child', 'Child', [ + createMetricItem('metric1', 'Metric 1'), + ]), + ]), + ]; + + const serialized = serializeForAPI(tree); + + expect(serialized).toHaveLength(1); + expect(serialized[0].uuid).toBe('parent'); + expect(serialized[0].children).toHaveLength(1); + expect((serialized[0].children![0] as any).uuid).toBe('child'); + expect((serialized[0].children![0] as any).children).toHaveLength(1); +}); + +test('serializeForAPI excludes nested empty folders', () => { + const tree: TreeItem[] = [ + createFolderItem('parent', 'Parent', [ + createFolderItem('emptyChild', 'Empty Child', []), + createMetricItem('metric1', 'Metric 1'), + ]), + ]; + + const serialized = serializeForAPI(tree); + + expect(serialized).toHaveLength(1); + expect(serialized[0].children).toHaveLength(1); + expect(serialized[0].children![0]).toEqual({ + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + }); +}); + +test('getProjection calculates correct depth when dragging down', () => { + const items: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'folder1', + depth: 1, + index: 0, + }, + { + uuid: 'folder2', + type: FoldersEditorItemType.Folder, + name: 'Folder 2', + parentId: null, + depth: 0, + index: 1, + children: [], + }, + ]; + + // Drag metric1 to folder2 position with no horizontal offset + const projection = getProjection(items, 'metric1', 'folder2', 0); + + expect(projection).not.toBeNull(); + expect(projection!.depth).toBeGreaterThanOrEqual(1); // Metrics need to be in a folder +}); + +test('getProjection returns null for invalid drag', () => { + const items: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + }, + ]; + + const projection = getProjection(items, 'nonexistent', 'folder1', 0); + + expect(projection).toBeNull(); +}); + +test('buildTree preserves order when moving nested folder with children to root', () => { + // This tests the scenario where a nested folder with children is dragged + // horizontally to become a root-level folder + // Initial structure: FolderA > NestedFolder > Metric1 + // After drag: NestedFolder (root) > Metric1, FolderA (root) + const flatItems: FlattenedTreeItem[] = [ + { + uuid: 'folderA', + type: FoldersEditorItemType.Folder, + name: 'Folder A', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'nestedFolder', + type: FoldersEditorItemType.Folder, + name: 'Nested Folder', + parentId: null, // Was 'folderA', now moved to root + depth: 0, // Was 1, now 0 + index: 1, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'nestedFolder', // Still points to nestedFolder + depth: 1, // Was 2, now 1 + index: 0, + }, + ]; + + const tree = buildTree(flatItems); + + // Both folders should be at root level + expect(tree).toHaveLength(2); + expect(tree[0].uuid).toBe('folderA'); + expect(tree[1].uuid).toBe('nestedFolder'); + + // nestedFolder should still have metric1 as its child + const nestedFolder = tree[1] as { children?: TreeItem[] }; + expect(nestedFolder.children).toBeDefined(); + expect(nestedFolder.children).toHaveLength(1); + expect(nestedFolder.children![0].uuid).toBe('metric1'); +}); + +test('buildTree handles reordered array correctly after drag', () => { + // Simulates the exact scenario of dragging NestedFolder out of ParentFolder + // This is the array AFTER handleDragEnd reorders it (before buildTree sorts by depth) + // + // Original: ParentFolder > NestedFolder > Metric1 + // After horizontal drag left: NestedFolder becomes sibling of ParentFolder + // + // The reordered array from handleDragEnd puts subtree at new position: + // [ParentFolder, NestedFolder, Metric1] where NestedFolder is now at depth 0 + const flatItems: FlattenedTreeItem[] = [ + { + uuid: 'parentFolder', + type: FoldersEditorItemType.Folder, + name: 'Parent Folder', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'nestedFolder', + type: FoldersEditorItemType.Folder, + name: 'Nested Folder', + parentId: null, // Changed from 'parentFolder' to null (moved to root) + depth: 0, // Changed from 1 to 0 (moved to root) + index: 1, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'nestedFolder', // Still nestedFolder (unchanged) + depth: 1, // Changed from 2 to 1 (parent moved up) + index: 0, + }, + ]; + + const tree = buildTree(flatItems); + + // Verify structure + expect(tree).toHaveLength(2); + + // Find nestedFolder in tree + const nestedFolder = tree.find( + item => item.uuid === 'nestedFolder', + ) as TreeItem & { children: TreeItem[] }; + expect(nestedFolder).toBeDefined(); + expect(nestedFolder.children).toHaveLength(1); + expect(nestedFolder.children[0].uuid).toBe('metric1'); + + // ParentFolder should be empty now + const parentFolder = tree.find( + item => item.uuid === 'parentFolder', + ) as TreeItem & { children: TreeItem[] }; + expect(parentFolder).toBeDefined(); + expect(parentFolder.children).toHaveLength(0); +}); + +test('getProjection calculates correct depth when dragging folder horizontally with children excluded', () => { + // When dragging a folder horizontally, its children should be excluded from the + // items array to avoid incorrect minDepth calculation. + // This tests that with children excluded, dragging left allows moving to root. + const itemsWithoutChildren: FlattenedTreeItem[] = [ + { + uuid: 'parentFolder', + type: FoldersEditorItemType.Folder, + name: 'Parent Folder', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'nestedFolder', + type: FoldersEditorItemType.Folder, + name: 'Nested Folder', + parentId: 'parentFolder', + depth: 1, + index: 1, + children: [], + }, + // Note: metric1 (child of nestedFolder) is excluded, simulating the drag state + ]; + + // Drag nestedFolder horizontally left (negative offset) + const projection = getProjection( + itemsWithoutChildren, + 'nestedFolder', + 'nestedFolder', + -DRAG_INDENTATION_WIDTH, // Drag left by one indentation + ); + + expect(projection).not.toBeNull(); + // With children excluded and dragging left, folder should move to depth 0 + expect(projection!.depth).toBe(0); + expect(projection!.parentId).toBeNull(); +}); + +test('getProjection incorrectly clamps depth when children are included', () => { + // This test documents why children must be excluded during projection: + // If children are included, minDepth is calculated from the child's depth, + // which prevents the folder from moving to a shallower depth. + const itemsWithChildren: FlattenedTreeItem[] = [ + { + uuid: 'parentFolder', + type: FoldersEditorItemType.Folder, + name: 'Parent Folder', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'nestedFolder', + type: FoldersEditorItemType.Folder, + name: 'Nested Folder', + parentId: 'parentFolder', + depth: 1, + index: 1, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'nestedFolder', + depth: 2, + index: 2, + }, + ]; + + // Drag nestedFolder horizontally left (negative offset) + const projection = getProjection( + itemsWithChildren, + 'nestedFolder', + 'nestedFolder', + -DRAG_INDENTATION_WIDTH, // Drag left by one indentation + ); + + expect(projection).not.toBeNull(); + // With children included, minDepth is metric1.depth = 2 + // So depth gets clamped to 2, which is incorrect behavior! + // This is why handleDragEnd should use flattenedItems (children excluded) + expect(projection!.depth).toBe(2); + expect(projection!.parentId).toBe('parentFolder'); +}); + +test('flattenTree and buildTree roundtrip preserves nested folder structure', () => { + // Test that flatten -> modify parentId/depth -> buildTree preserves children + const originalTree: TreeItem[] = [ + createFolderItem('parentFolder', 'Parent Folder', [ + createFolderItem('nestedFolder', 'Nested Folder', [ + createMetricItem('metric1', 'Metric 1'), + ]), + ]), + ]; + + // Flatten the tree + const flattened = flattenTree(originalTree); + + // Verify flattened structure + expect(flattened).toHaveLength(3); + expect(flattened[0]).toMatchObject({ + uuid: 'parentFolder', + depth: 0, + parentId: null, + }); + expect(flattened[1]).toMatchObject({ + uuid: 'nestedFolder', + depth: 1, + parentId: 'parentFolder', + }); + expect(flattened[2]).toMatchObject({ + uuid: 'metric1', + depth: 2, + parentId: 'nestedFolder', + }); + + // Simulate moving nestedFolder to root level (horizontal drag left) + const modifiedFlattened = flattened.map(item => { + if (item.uuid === 'nestedFolder') { + return { ...item, depth: 0, parentId: null }; + } + if (item.uuid === 'metric1') { + return { ...item, depth: 1 }; // Depth decreases by 1, parentId stays same + } + return item; + }); + + // Rebuild tree + const rebuiltTree = buildTree(modifiedFlattened); + + // Verify rebuilt structure + expect(rebuiltTree).toHaveLength(2); + + const parentFolder = rebuiltTree.find( + i => i.uuid === 'parentFolder', + ) as TreeItem & { children: TreeItem[] }; + const nestedFolder = rebuiltTree.find( + i => i.uuid === 'nestedFolder', + ) as TreeItem & { children: TreeItem[] }; + + expect(parentFolder).toBeDefined(); + expect(nestedFolder).toBeDefined(); + expect(parentFolder.children).toHaveLength(0); // nestedFolder moved out + expect(nestedFolder.children).toHaveLength(1); // metric1 still in nestedFolder + expect(nestedFolder.children[0].uuid).toBe('metric1'); +}); + +test('getProjection nests item under folder when dragging down with offset', () => { + const items: FlattenedTreeItem[] = [ + { + uuid: 'folder1', + type: FoldersEditorItemType.Folder, + name: 'Folder 1', + parentId: null, + depth: 0, + index: 0, + children: [], + }, + { + uuid: 'metric1', + type: FoldersEditorItemType.Metric, + name: 'Metric 1', + parentId: 'folder1', + depth: 1, + index: 0, + }, + { + uuid: 'folder2', + type: FoldersEditorItemType.Folder, + name: 'Folder 2', + parentId: null, + depth: 0, + index: 1, + children: [], + }, + ]; + + // Drag folder2 down over metric1's position with horizontal offset to nest under folder1 + // When dragging down (activeIndex > overIndex), after move: previousItem = items[overIndex] = metric1 + // metric1 is at depth 1 and is not a folder, so maxDepth = metric1.depth = 1 + const projection = getProjection( + items, + 'folder2', + 'metric1', + DRAG_INDENTATION_WIDTH, // Move right by indentation width + ); + + expect(projection).not.toBeNull(); + // folder2 starts at depth 0, drag offset adds 1, so projected = 1 + // maxDepth from metric1 (non-folder) = metric1.depth = 1 + expect(projection!.depth).toBe(1); + expect(projection!.parentId).toBe('folder1'); +}); diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts new file mode 100644 index 00000000000..10dec0d28ab --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/treeUtils.ts @@ -0,0 +1,332 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Tree manipulation utilities for dnd-kit drag and drop operations. + * Handles flattening, building, and projecting tree structures. + */ + +import type { UniqueIdentifier } from '@dnd-kit/core'; +import { + DatasourceFolder, + DatasourceFolderItem, +} from 'src/explore/components/DatasourcePanel/types'; +import { FoldersEditorItemType } from '../types'; +import { + TreeItem, + FlattenedTreeItem, + DRAG_INDENTATION_WIDTH, + MAX_DEPTH, +} from './constants'; + +function getDragDepth( + offset: number, + indentationWidth: number = DRAG_INDENTATION_WIDTH, +): number { + return Math.round(offset / indentationWidth); +} + +function getMaxDepth(previousItem: FlattenedTreeItem | undefined): number { + if (previousItem) { + if (previousItem.type === FoldersEditorItemType.Folder) { + return Math.min(previousItem.depth + 1, MAX_DEPTH); + } + return previousItem.depth; + } + return 0; +} + +function getMinDepth( + nextItem: FlattenedTreeItem | undefined, + activeItem: FlattenedTreeItem, +): number { + // Items must always be inside a folder + if (activeItem.type !== FoldersEditorItemType.Folder) { + return 1; + } + if (nextItem) { + return nextItem.depth; + } + return 0; +} + +/** + * Project the target depth and parent based on drag position. + * Calculates adjacent items directly without creating intermediate arrays. + */ +export function getProjection( + items: FlattenedTreeItem[], + activeId: UniqueIdentifier, + overId: UniqueIdentifier, + dragOffset: number, + indentationWidth: number = DRAG_INDENTATION_WIDTH, + // Optional pre-built index map for repeated calls with same items array + indexMap?: Map, +) { + // Use provided map or fall back to findIndex + const overItemIndex = indexMap + ? (indexMap.get(overId as string) ?? -1) + : items.findIndex(({ uuid }) => uuid === overId); + const activeItemIndex = indexMap + ? (indexMap.get(activeId as string) ?? -1) + : items.findIndex(({ uuid }) => uuid === activeId); + const activeItem = items[activeItemIndex]; + + if (!activeItem || overItemIndex === -1) { + return null; + } + + let previousItem: FlattenedTreeItem | undefined; + let nextItem: FlattenedTreeItem | undefined; + let previousItemIndex: number; + let nextItemIndex: number; + + if (activeItemIndex < overItemIndex) { + previousItemIndex = overItemIndex; + nextItemIndex = overItemIndex + 1; + } else if (activeItemIndex > overItemIndex) { + previousItemIndex = overItemIndex - 1; + nextItemIndex = overItemIndex; + } else { + previousItemIndex = overItemIndex - 1; + nextItemIndex = overItemIndex + 1; + } + + previousItem = items[previousItemIndex]; + nextItem = items[nextItemIndex]; + + // Skip over the active item if it's adjacent + if (previousItem?.uuid === activeId) { + previousItemIndex -= 1; + previousItem = items[previousItemIndex]; + } + if (nextItem?.uuid === activeId) { + nextItemIndex += 1; + nextItem = items[nextItemIndex]; + } + + const dragDepth = getDragDepth(dragOffset, indentationWidth); + const projectedDepth = activeItem.depth + dragDepth; + const maxDepth = getMaxDepth(previousItem); + const minDepth = getMinDepth(nextItem, activeItem); + + let depth = projectedDepth; + if (projectedDepth >= maxDepth) { + depth = maxDepth; + } else if (projectedDepth < minDepth) { + depth = minDepth; + } + + let parentId: string | null = null; + if (depth > 0 && previousItem) { + if (depth === previousItem.depth) { + ({ parentId } = previousItem); + } else if (depth > previousItem.depth) { + parentId = previousItem.uuid; + } else { + const searchEnd = + activeItemIndex < overItemIndex ? overItemIndex : overItemIndex - 1; + for (let i = searchEnd; i >= 0; i -= 1) { + if (items[i].uuid !== activeId && items[i].depth === depth) { + ({ parentId } = items[i]); + break; + } + } + } + } + + return { + depth, + maxDepth, + minDepth, + parentId, + }; +} + +function flatten( + items: TreeItem[], + parentId: string | null = null, + depth: number = 0, + result: FlattenedTreeItem[] = [], +): FlattenedTreeItem[] { + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const flatItem: FlattenedTreeItem = { + uuid: item.uuid, + type: item.type, + name: item.name, + description: 'description' in item ? item.description : undefined, + children: 'children' in item ? item.children : undefined, + parentId, + depth, + index, + collapsed: 'children' in item && (item as any).collapsed, + }; + + result.push(flatItem); + + if ( + item.type === FoldersEditorItemType.Folder && + 'children' in item && + item.children + ) { + flatten(item.children, item.uuid, depth + 1, result); + } + } + + return result; +} + +export function flattenTree(items: TreeItem[]): FlattenedTreeItem[] { + return flatten(items); +} + +export function buildTree(flattenedItems: FlattenedTreeItem[]): TreeItem[] { + const root: TreeItem[] = []; + const nodes = new Map(); + + // First pass: create all nodes + for (const item of flattenedItems) { + const { uuid, type, name, description } = item; + + const treeItem: TreeItem = + type === FoldersEditorItemType.Folder + ? ({ + uuid, + type, + name, + description, + children: [], + } as DatasourceFolder) + : ({ + uuid, + type, + name, + } as DatasourceFolderItem); + + nodes.set(uuid, treeItem); + } + + // Second pass: link children to parents (iteration order preserves structure) + for (const item of flattenedItems) { + const { uuid, parentId } = item; + const treeItem = nodes.get(uuid)!; + + if (!parentId) { + root.push(treeItem); + } else { + const parent = nodes.get(parentId); + if ( + parent && + parent.type === FoldersEditorItemType.Folder && + 'children' in parent + ) { + parent.children!.push(treeItem); + } else if (!parent) { + root.push(treeItem); + } + } + } + + return root; +} + +export function removeChildrenOf( + items: FlattenedTreeItem[], + ids: UniqueIdentifier[], +): FlattenedTreeItem[] { + const excludeParentIds = new Set(ids); + + return items.filter(item => { + if (item.parentId && excludeParentIds.has(item.parentId)) { + if (item.children?.length) { + excludeParentIds.add(item.uuid); + } + return false; + } + + return true; + }); +} + +/** + * Serialize tree for API. Empty folders are excluded. + */ +export function serializeForAPI(items: TreeItem[]): DatasourceFolder[] { + const serializeChildren = ( + children: TreeItem[] | undefined, + ): Array< + DatasourceFolder | { uuid: string; type: FoldersEditorItemType } + > => { + if (!children || children.length === 0) return []; + + return children + .map(child => { + if ( + child.type === FoldersEditorItemType.Folder && + 'children' in child + ) { + const serializedChildren = serializeChildren(child.children); + + if (serializedChildren.length === 0) { + return null; + } + + return { + uuid: child.uuid, + type: child.type, + name: child.name, + description: child.description, + children: serializedChildren, + } as DatasourceFolder; + } + return { + uuid: child.uuid, + type: child.type, + }; + }) + .filter( + ( + child, + ): child is + | DatasourceFolder + | { uuid: string; type: FoldersEditorItemType } => child !== null, + ); + }; + + return items + .filter(item => item.type === FoldersEditorItemType.Folder) + .map(item => { + const serializedChildren = + 'children' in item ? serializeChildren(item.children) : []; + + if (serializedChildren.length === 0) { + return null; + } + + return { + uuid: item.uuid, + type: item.type, + name: item.name, + description: 'description' in item ? item.description : undefined, + children: serializedChildren, + } as DatasourceFolder; + }) + .filter((folder): folder is DatasourceFolder => folder !== null); +} diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/types.ts b/superset-frontend/src/components/Datasource/FoldersEditor/types.ts new file mode 100644 index 00000000000..06bf1bdd739 --- /dev/null +++ b/superset-frontend/src/components/Datasource/FoldersEditor/types.ts @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Metric, ColumnMeta } from '@superset-ui/chart-controls'; +import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types'; + +export interface FoldersEditorProps { + folders: DatasourceFolder[]; + metrics: Metric[]; + columns: ColumnMeta[]; + onChange: (folders: DatasourceFolder[]) => void; +} diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx index 242ab820a92..1d4480f11b3 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx @@ -82,6 +82,12 @@ import Fieldset from '../Fieldset'; import Field from '../Field'; import { fetchSyncedColumns, updateColumns } from '../../utils'; import DatasetUsageTab from './components/DatasetUsageTab'; +import { + DEFAULT_COLUMNS_FOLDER_UUID, + DEFAULT_METRICS_FOLDER_UUID, +} from '../../FoldersEditor/constants'; +import { validateFolders } from '../../FoldersEditor/folderValidation'; +import FoldersEditor from '../../FoldersEditor'; const extensionsRegistry = getExtensionsRegistry(); @@ -211,6 +217,7 @@ const TABS_KEYS = { COLUMNS: 'COLUMNS', CALCULATED_COLUMNS: 'CALCULATED_COLUMNS', USAGE: 'USAGE', + FOLDERS: 'FOLDERS', SETTINGS: 'SETTINGS', SPATIAL: 'SPATIAL', }; @@ -628,6 +635,7 @@ class DatasourceEditor extends PureComponent { calculatedColumns: props.datasource.columns.filter( col => !!col.expression, ), + folders: props.datasource.folders || [], metadataLoading: false, activeTabKey: TABS_KEYS.SOURCE, datasourceType: props.datasource.sql @@ -657,6 +665,7 @@ class DatasourceEditor extends PureComponent { this.handleTabSelect = this.handleTabSelect.bind(this); this.formatSql = this.formatSql.bind(this); this.fetchUsageData = this.fetchUsageData.bind(this); + this.handleFoldersChange = this.handleFoldersChange.bind(this); this.currencies = ensureIsArray(props.currencies).map(currencyCode => ({ value: currencyCode, label: `${getCurrencySymbol({ @@ -677,6 +686,7 @@ class DatasourceEditor extends PureComponent { ...this.state.datasource, sql, columns: [...this.state.databaseColumns, ...this.state.calculatedColumns], + folders: this.state.folders, }; this.props.onChange(newDatasource, this.state.errors); @@ -710,6 +720,21 @@ class DatasourceEditor extends PureComponent { this.setState({ datasourceType }, this.onChange); } + handleFoldersChange(folders) { + const userMadeFolders = folders.filter( + f => + f.uuid !== DEFAULT_METRICS_FOLDER_UUID && + f.uuid !== DEFAULT_COLUMNS_FOLDER_UUID && + f.children.length > 0, + ); + this.setState({ folders: userMadeFolders }, () => { + this.onDatasourceChange({ + ...this.state.datasource, + folders: userMadeFolders, + }); + }); + } + setColumns(obj) { // update calculatedColumns or databaseColumns this.setState(obj, this.validateAndChange); @@ -1067,6 +1092,12 @@ class DatasourceEditor extends PureComponent { errors = errors.concat([t('Invalid currency code in saved metrics')]); } + // Validate folders + if (this.state.folders?.length > 0) { + const folderValidation = validateFolders(this.state.folders); + errors = errors.concat(folderValidation.errors); + } + this.setState({ errors }, callback); } @@ -2033,6 +2064,27 @@ class DatasourceEditor extends PureComponent { ), }, + ...(isFeatureEnabled(FeatureFlag.DatasetFolders) + ? [ + { + key: TABS_KEYS.FOLDERS, + label: ( + + ), + children: ( + + ), + }, + ] + : []), { key: TABS_KEYS.SETTINGS, label: t('Settings'), diff --git a/superset-frontend/src/components/Datasource/types.ts b/superset-frontend/src/components/Datasource/types.ts index 62cd19921dd..046458c73e5 100644 --- a/superset-frontend/src/components/Datasource/types.ts +++ b/superset-frontend/src/components/Datasource/types.ts @@ -90,3 +90,9 @@ export interface CRUDCollectionState { sortColumn: string; sort: SortOrder; } + +export enum FoldersEditorItemType { + Folder = 'folder', + Column = 'column', + Metric = 'metric', +} diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx index 57a88b1f807..b26bf429323 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx @@ -40,6 +40,7 @@ import { DndColumnSelect, DndMetricSelect, } from '../controls/DndColumnSelectControl'; +import { FoldersEditorItemType } from 'src/components/Datasource/types'; jest.mock( 'react-virtualized-auto-sizer', @@ -64,16 +65,16 @@ const datasourceWithFolders: IDatasource = { folders: [ { name: 'Test folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '1', children: [ { name: 'Test nested folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '1.1', children: [ { - type: 'metric', + type: FoldersEditorItemType.Metric, uuid: metrics[0].uuid, name: metrics[0].metric_name, }, @@ -83,16 +84,16 @@ const datasourceWithFolders: IDatasource = { }, { name: 'Second test folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '2', children: [ { - type: 'column', + type: FoldersEditorItemType.Column, uuid: columns[0].uuid, name: columns[0].column_name, }, { - type: 'column', + type: FoldersEditorItemType.Column, uuid: columns[1].uuid, name: columns[1].column_name, }, @@ -427,15 +428,15 @@ test('Default Metrics and Columns folders dont render when all metrics and colum folders: [ { name: 'Test folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '1', children: [ { name: 'Test nested folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '1.1', children: metrics.map(m => ({ - type: 'metric' as const, + type: FoldersEditorItemType.Metric, uuid: m.uuid, name: m.metric_name, })), @@ -444,10 +445,10 @@ test('Default Metrics and Columns folders dont render when all metrics and colum }, { name: 'Second test folder', - type: 'folder', + type: FoldersEditorItemType.Folder, uuid: '2', children: columns.map(c => ({ - type: 'column', + type: FoldersEditorItemType.Column, uuid: c.uuid, name: c.column_name, })), diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx index b96905ba2eb..621b33a323f 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx @@ -24,6 +24,8 @@ import { screen, userEvent, render } from 'spec/helpers/testing-library'; import DatasourcePanelItem, { DatasourcePanelItemProps, } from './DatasourcePanelItem'; +import { FoldersEditorItemType } from 'src/components/Datasource/types'; +import { MetricItem, ColumnItem } from './types'; const mockData: DatasourcePanelItemProps['data'] = { flattenedItems: [ @@ -34,7 +36,7 @@ const mockData: DatasourcePanelItemProps['data'] = { folderId: '1', height: 32, index: idx, - item: { ...m, type: 'metric' as const }, + item: { ...m, type: FoldersEditorItemType.Metric } as MetricItem, })), { type: 'divider', depth: 0, folderId: '1', height: 16 }, { type: 'header', depth: 0, folderId: '2', height: 50 }, @@ -44,7 +46,7 @@ const mockData: DatasourcePanelItemProps['data'] = { folderId: '2', height: 32, index: idx, - item: { ...m, type: 'column' as const }, + item: { ...m, type: FoldersEditorItemType.Column } as ColumnItem, })), ], folderMap: new Map([ @@ -54,7 +56,9 @@ const mockData: DatasourcePanelItemProps['data'] = { id: '1', isCollapsed: false, name: 'Metrics', - items: metrics.map(m => ({ ...m, type: 'metric' })), + items: metrics.map( + m => ({ ...m, type: FoldersEditorItemType.Metric }) as MetricItem, + ), totalItems: metrics.length, showingItems: metrics.length, }, @@ -65,7 +69,9 @@ const mockData: DatasourcePanelItemProps['data'] = { id: '2', isCollapsed: false, name: 'Columns', - items: columns.map(c => ({ ...c, type: 'column' })), + items: columns.map( + c => ({ ...c, type: FoldersEditorItemType.Column }) as ColumnItem, + ), totalItems: columns.length, showingItems: columns.length, }, diff --git a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts index 6099b72c1ea..33f922c69f6 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts +++ b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts @@ -20,6 +20,7 @@ import { Metric } from '@superset-ui/core'; import { transformDatasourceWithFolders } from './transformDatasourceFolders'; import { DatasourceFolder, DatasourcePanelColumn } from './types'; +import { FoldersEditorItemType } from 'src/components/Datasource/types'; const mockMetrics: Metric[] = [ { metric_name: 'metric1', uuid: 'metric1-uuid', expression: 'SUM(col1)' }, @@ -48,32 +49,46 @@ test('transforms data into default folders when no folder config is provided', ( 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('metric'); + expect(result[0].items[0].type).toBe(FoldersEditorItemType.Metric); expect(result[1].id).toBe('columns-default'); expect(result[1].name).toBe('Columns'); expect(result[1].items).toHaveLength(3); expect(result[1].items[0].uuid).toBe('column1-uuid'); - expect(result[1].items[0].type).toBe('column'); + expect(result[1].items[0].type).toBe(FoldersEditorItemType.Column); }); test('transforms data according to folder configuration', () => { const folderConfig: DatasourceFolder[] = [ { uuid: 'folder1', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Important Metrics', description: 'Key metrics folder', children: [ - { type: 'metric', uuid: 'metric1-uuid', name: 'metric1' }, - { type: 'metric', uuid: 'metric2-uuid', name: 'metric2' }, + { + type: FoldersEditorItemType.Metric, + uuid: 'metric1-uuid', + name: 'metric1', + }, + { + type: FoldersEditorItemType.Metric, + uuid: 'metric2-uuid', + name: 'metric2', + }, ], }, { uuid: 'folder2', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Key Dimensions', - children: [{ type: 'column', uuid: 'column1-uuid', name: 'column1' }], + children: [ + { + type: FoldersEditorItemType.Column, + uuid: 'column1-uuid', + name: 'column1', + }, + ], }, ]; @@ -115,16 +130,26 @@ test('handles nested folder structures', () => { const folderConfig: DatasourceFolder[] = [ { uuid: 'parent-folder', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Parent Folder', children: [ { uuid: 'child-folder', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Child Folder', - children: [{ type: 'metric', uuid: 'metric1-uuid', name: 'metric1' }], + children: [ + { + type: FoldersEditorItemType.Metric, + uuid: 'metric1-uuid', + name: 'metric1', + }, + ], + }, + { + type: FoldersEditorItemType.Column, + uuid: 'column1-uuid', + name: 'column1', }, - { type: 'column', uuid: 'column1-uuid', name: 'column1' }, ], }, ]; @@ -153,7 +178,7 @@ test('handles empty children arrays', () => { const folderConfig: DatasourceFolder[] = [ { uuid: 'empty-folder', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Empty Folder', children: [], }, @@ -176,20 +201,24 @@ test('handles non-existent metric and column UUIDs in folder config', () => { const folderConfig: DatasourceFolder[] = [ { uuid: 'folder1', - type: 'folder', + type: FoldersEditorItemType.Folder, name: 'Test Folder', children: [ { - type: 'metric', + type: FoldersEditorItemType.Metric, uuid: 'non-existent-metric', name: 'Missing Metric', }, { - type: 'column', + type: FoldersEditorItemType.Column, uuid: 'non-existent-column', name: 'Missing Column', }, - { type: 'metric', uuid: 'metric1-uuid', name: 'Existing Metric' }, + { + type: FoldersEditorItemType.Metric, + uuid: 'metric1-uuid', + name: 'Existing Metric', + }, ], }, ]; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts index e170d7ca050..414b7f85a62 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts +++ b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts @@ -18,6 +18,7 @@ */ import { t } from '@apache-superset/core'; import { Metric } from '@superset-ui/core'; +import { FoldersEditorItemType } from 'src/components/Datasource/types'; import { ColumnItem, DatasourceFolder, @@ -161,11 +162,11 @@ export const transformDatasourceWithFolders = ( ): Folder[] => { const metricsWithType: MetricItem[] = metricsToDisplay.map(metric => ({ ...metric, - type: 'metric', + type: FoldersEditorItemType.Metric, })); const columnsWithType: ColumnItem[] = columnsToDisplay.map(column => ({ ...column, - type: 'column', + type: FoldersEditorItemType.Column, })); return transformToFolderStructure( diff --git a/superset-frontend/src/explore/components/DatasourcePanel/types.ts b/superset-frontend/src/explore/components/DatasourcePanel/types.ts index 0639f3c8278..0447d168bb1 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/types.ts +++ b/superset-frontend/src/explore/components/DatasourcePanel/types.ts @@ -17,6 +17,7 @@ * under the License. */ import { ColumnMeta, Metric } from '@superset-ui/chart-controls'; +import { FoldersEditorItemType } from 'src/components/Datasource/types'; import { DndItemType } from '../DndItemType'; export type DndItemValue = ColumnMeta | Metric; @@ -48,24 +49,25 @@ export type DatasourcePanelColumn = { type?: string; }; +export type DatasourceFolderItem = { + type: FoldersEditorItemType.Column | FoldersEditorItemType.Metric; + uuid: string; + name: string; +}; export type DatasourceFolder = { uuid: string; - type: 'folder'; + type: FoldersEditorItemType.Folder; name: string; description?: string; - children?: ( - | DatasourceFolder - | { type: 'metric'; uuid: string; name: string } - | { type: 'column'; uuid: string; name: string } - )[]; + children?: (DatasourceFolder | DatasourceFolderItem)[]; }; export type MetricItem = Metric & { - type: 'metric'; + type: FoldersEditorItemType.Metric; }; export type ColumnItem = DatasourcePanelColumn & { - type: 'column'; + type: FoldersEditorItemType.Column; }; export type FolderItem = MetricItem | ColumnItem; diff --git a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx index c8aaf488555..dc3a8776552 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx @@ -46,7 +46,7 @@ export default function FilterDefinitionOption({ if (option.saved_metric_name) { return ( ); @@ -62,7 +62,7 @@ export default function FilterDefinitionOption({ if (option.label) { return ( ); diff --git a/superset/commands/dataset/update.py b/superset/commands/dataset/update.py index fdac55a25d6..be86cd876b4 100644 --- a/superset/commands/dataset/update.py +++ b/superset/commands/dataset/update.py @@ -212,27 +212,26 @@ class UpdateDatasetCommand(UpdateMixin, BaseCommand): valid_uuids: set[UUID] = set() if metrics: valid_uuids.update( - UUID(metric["uuid"]) for metric in metrics if "uuid" in metric + metric["uuid"] for metric in metrics if "uuid" in metric ) else: valid_uuids.update(metric.uuid for metric in self._model.metrics) if columns: valid_uuids.update( - UUID(column["uuid"]) for column in columns if "uuid" in column + column["uuid"] for column in columns if "uuid" in column ) else: valid_uuids.update(column.uuid for column in self._model.columns) + schema = FolderSchema(many=True) try: - validate_folders(folders, valid_uuids) + loaded_folders = schema.load(folders) + validate_folders(loaded_folders, valid_uuids) + self._properties["folders"] = schema.dump(loaded_folders) except ValidationError as ex: exceptions.append(ex) - # dump schema to convert UUID to string - schema = FolderSchema(many=True) - self._properties["folders"] = schema.dump(folders) - def _validate_columns( self, columns: list[dict[str, Any]], exceptions: list[ValidationError] ) -> None: