diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 3749e68dd77..9d6ce44ff8d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -48975,6 +48975,7 @@ "@types/react": "*", "@types/react-loadable": "*", "@types/tinycolor2": "*", + "nanoid": "^5.0.9", "react": "^17.0.2", "react-loadable": "^5.5.0", "tinycolor2": "*" 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 c424cde518e..59beeee4c56 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 @@ -42,7 +42,7 @@ const FlexRowContainer = styled.div` `; export interface MetricOptionProps { - metric: Omit & { label?: string }; + metric: Omit & { label?: string }; openInNewWindow?: boolean; showFormula?: boolean; showType?: boolean; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/components/labelUtils.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/components/labelUtils.tsx index 4ef491f13ce..4102fb2b9f3 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/components/labelUtils.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/components/labelUtils.tsx @@ -97,7 +97,7 @@ export const getColumnTooltipNode = ( ); }; -type MetricType = Omit & { label?: string }; +type MetricType = Omit & { label?: string }; export const getMetricTooltipNode = ( metric: MetricType, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts index efdfef6c4f1..05b4d4df3dc 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/fixtures.ts @@ -121,6 +121,7 @@ export const TestDataset: Dataset = { main_dttm_col: 'ds', metrics: [ { + uuid: '123', certification_details: null, certified_by: null, d3format: null, diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx index 79910895b24..682075b5c1c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx @@ -32,6 +32,7 @@ describe('defineSavedMetrics', () => { { metric_name: 'COUNT(*) non-default-dataset-metric', expression: 'COUNT(*) non-default-dataset-metric', + uuid: '1', }, ], type: DatasourceType.Table, @@ -48,6 +49,7 @@ describe('defineSavedMetrics', () => { { metric_name: 'COUNT(*) non-default-dataset-metric', expression: 'COUNT(*) non-default-dataset-metric', + uuid: '1', }, ]); // @ts-ignore diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/mainMetric.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/mainMetric.test.ts index 9385537d292..4b009766428 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/mainMetric.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/mainMetric.test.ts @@ -24,15 +24,24 @@ describe('mainMetric', () => { expect(mainMetric(null)).toBeUndefined(); }); it('prefers the "count" metric when first', () => { - const metrics = [{ metric_name: 'count' }, { metric_name: 'foo' }]; + const metrics = [ + { metric_name: 'count', uuid: '1' }, + { metric_name: 'foo', uuid: '2' }, + ]; expect(mainMetric(metrics)).toBe('count'); }); it('prefers the "count" metric when not first', () => { - const metrics = [{ metric_name: 'foo' }, { metric_name: 'count' }]; + const metrics = [ + { metric_name: 'foo', uuid: '1' }, + { metric_name: 'count', uuid: '2' }, + ]; expect(mainMetric(metrics)).toBe('count'); }); it('selects the first metric when "count" is not an option', () => { - const metrics = [{ metric_name: 'foo' }, { metric_name: 'not_count' }]; + const metrics = [ + { metric_name: 'foo', uuid: '2' }, + { metric_name: 'not_count', uuid: '2' }, + ]; expect(mainMetric(metrics)).toBe('foo'); }); }); diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index ab9b0cbc279..158aaa15d35 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -81,6 +81,7 @@ "@types/react": "*", "@types/react-loadable": "*", "@types/tinycolor2": "*", + "nanoid": "^5.0.9", "react": "^17.0.2", "react-loadable": "^5.5.0", "tinycolor2": "*" diff --git a/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.test.tsx b/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.test.tsx index 02dff90621e..8239cc64c70 100644 --- a/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.test.tsx @@ -59,3 +59,37 @@ test('should truncate', () => { expect(isTruncated).toBe(true); }); + +test('should not truncate with vertical orientation', () => { + const ref = { current: document.createElement('p') }; + Object.defineProperty(ref.current, 'offsetHeight', { get: () => 100 }); + Object.defineProperty(ref.current, 'scrollHeight', { get: () => 50 }); + jest.spyOn(global.React, 'useRef').mockReturnValue({ current: ref.current }); + + const { result } = renderHook(() => + useCSSTextTruncation({ + isVertical: true, + isHorizontal: false, + }), + ); + const [, isTruncated] = result.current; + + expect(isTruncated).toBe(false); +}); + +test('should truncate with vertical orientation', () => { + const ref = { current: document.createElement('p') }; + Object.defineProperty(ref.current, 'offsetHeight', { get: () => 50 }); + Object.defineProperty(ref.current, 'scrollHeight', { get: () => 100 }); + jest.spyOn(global.React, 'useRef').mockReturnValue({ current: ref.current }); + + const { result } = renderHook(() => + useCSSTextTruncation({ + isVertical: true, + isHorizontal: false, + }), + ); + const [, isTruncated] = result.current; + + expect(isTruncated).toBe(true); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.ts b/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.ts index 335e26a2263..8bfefc0e29e 100644 --- a/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.ts +++ b/superset-frontend/packages/superset-ui-core/src/hooks/useTruncation/useCSSTextTruncation.ts @@ -36,24 +36,37 @@ export const truncationCSS = css` * to be displayed, this hook returns a ref to attach to the text * element and a boolean for whether that element is currently truncated. */ -const useCSSTextTruncation = (): [ - RefObject, - boolean, -] => { +const useCSSTextTruncation = ( + { isVertical, isHorizontal } = { isVertical: false, isHorizontal: true }, +): [RefObject, boolean] => { const [isTruncated, setIsTruncated] = useState(true); const ref = useRef(null); const [offsetWidth, setOffsetWidth] = useState(0); const [scrollWidth, setScrollWidth] = useState(0); + const [offsetHeight, setOffsetHeight] = useState(0); + const [scrollHeight, setScrollHeight] = useState(0); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { setOffsetWidth(ref.current?.offsetWidth ?? 0); setScrollWidth(ref.current?.scrollWidth ?? 0); + setOffsetHeight(ref.current?.offsetHeight ?? 0); + setScrollHeight(ref.current?.scrollHeight ?? 0); }); useEffect(() => { - setIsTruncated(offsetWidth < scrollWidth); - }, [offsetWidth, scrollWidth]); + setIsTruncated( + (isVertical && offsetHeight < scrollHeight) || + (isHorizontal && offsetWidth < scrollWidth), + ); + }, [ + offsetWidth, + scrollWidth, + offsetHeight, + scrollHeight, + isVertical, + isHorizontal, + ]); return [ref, isTruncated]; }; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts index ab5ff950cc1..c5ce93c1e91 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { nanoid } from 'nanoid'; import { Column } from './Column'; import { Metric } from './Metric'; @@ -58,6 +59,7 @@ export const DEFAULT_METRICS: Metric[] = [ { metric_name: 'COUNT(*)', expression: 'COUNT(*)', + uuid: nanoid(), }, ]; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts index 229852373a8..15b59fb22ab 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts @@ -60,6 +60,7 @@ export type SavedMetric = string; */ export interface Metric { id?: number; + uuid: string; metric_name: string; expression?: Maybe; certification_details?: Maybe; diff --git a/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts b/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts index c80f3d69500..aa77b74349e 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/types/Datasource.test.ts @@ -20,10 +20,10 @@ import { DatasourceType, DEFAULT_METRICS } from '@superset-ui/core'; test('DEFAULT_METRICS', () => { expect(DEFAULT_METRICS).toEqual([ - { + expect.objectContaining({ metric_name: 'COUNT(*)', expression: 'COUNT(*)', - }, + }), ]); }); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts index 4ccedd1e7f2..e7ce20cc072 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts @@ -149,6 +149,7 @@ describe('BigNumberWithTrendline', () => { label: 'value', metric_name: 'value', d3format: '.2f', + uuid: '1', }, ], }, @@ -174,6 +175,7 @@ describe('BigNumberWithTrendline', () => { metric_name: 'value', d3format: '.2f', currency: { symbol: 'USD', symbolPosition: 'prefix' }, + uuid: '1', }, ], }, diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourceItems.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourceItems.tsx new file mode 100644 index 00000000000..53e55b60543 --- /dev/null +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourceItems.tsx @@ -0,0 +1,165 @@ +/** + * 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, useState } from 'react'; +import { VariableSizeList as List } from 'react-window'; +import { FlattenedItem, Folder } from './types'; +import DatasourcePanelItem from './DatasourcePanelItem'; + +const BORDER_WIDTH = 2; +const HEADER_ITEM_HEIGHT = 50; +const METRIC_OR_COLUMN_ITEM_HEIGHT = 32; +const SUBTITLE_ITEM_HEIGHT = 32; +const DIVIDER_ITEM_HEIGHT = 16; + +const flattenFolderStructure = ( + foldersToFlatten: Folder[], + collapsedFolderIds: Set, + depth = 0, + folderMap: Map = new Map(), +): { flattenedItems: FlattenedItem[]; folderMap: Map } => { + const flattenedItems: FlattenedItem[] = []; + + foldersToFlatten.forEach((folder, idx) => { + folderMap.set(folder.id, folder); + + flattenedItems.push({ + type: 'header', + folderId: folder.id, + depth, + height: HEADER_ITEM_HEIGHT, + }); + + if (!collapsedFolderIds.has(folder.id)) { + flattenedItems.push({ + type: 'subtitle', + folderId: folder.id, + depth, + height: SUBTITLE_ITEM_HEIGHT, + totalItems: folder.totalItems, + showingItems: folder.showingItems, + }); + folder.items.forEach(item => { + flattenedItems.push({ + type: 'item', + folderId: folder.id, + depth, + item, + height: METRIC_OR_COLUMN_ITEM_HEIGHT, + }); + }); + + if (folder.subFolders && folder.subFolders.length > 0) { + const { flattenedItems: subItems } = flattenFolderStructure( + folder.subFolders, + collapsedFolderIds, + depth + 1, + folderMap, + ); + + flattenedItems.push(...subItems); + } + } + if (depth === 0 && idx !== foldersToFlatten.length - 1) { + flattenedItems.push({ + type: 'divider', + folderId: folder.id, + depth, + height: DIVIDER_ITEM_HEIGHT, + }); + } + }); + + return { flattenedItems, folderMap }; +}; + +interface DatasourceItemsProps { + width: number; + height: number; + folders: Folder[]; +} +export const DatasourceItems = ({ + width, + height, + folders, +}: DatasourceItemsProps) => { + const listRef = useRef(null); + const [collapsedFolderIds, setCollapsedFolderIds] = useState>( + new Set( + folders.filter(folder => folder.isCollapsed).map(folder => folder.id), + ), + ); + + const { flattenedItems, folderMap } = useMemo( + () => flattenFolderStructure(folders, collapsedFolderIds), + [folders, collapsedFolderIds], + ); + + const handleToggleCollapse = useCallback((folderId: string) => { + setCollapsedFolderIds(prevIds => { + const newIds = new Set(prevIds); + if (newIds.has(folderId)) { + newIds.delete(folderId); + } else { + newIds.add(folderId); + } + return newIds; + }); + }, []); + + useEffect(() => { + // reset the list cache when flattenedItems length changes to recalculate the heights + listRef.current?.resetAfterIndex(0); + }, [flattenedItems]); + + const getItemSize = useCallback( + (index: number) => flattenedItems[index].height, + [flattenedItems], + ); + + const itemData = useMemo( + () => ({ + flattenedItems, + folderMap, + width, + onToggleCollapse: handleToggleCollapse, + collapsedFolderIds, + }), + [ + flattenedItems, + folderMap, + width, + handleToggleCollapse, + collapsedFolderIds, + ], + ); + + return ( + + {DatasourcePanelItem} + + ); +}; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx index 84b40969b5b..19a91463d39 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx @@ -59,6 +59,48 @@ const datasource: IDatasource = { datasource_name: 'table1', }; +const datasourceWithFolders: IDatasource = { + ...datasource, + folders: [ + { + name: 'Test folder', + type: 'folder', + uuid: '1', + children: [ + { + name: 'Test nested folder', + type: 'folder', + uuid: '1.1', + children: [ + { + type: 'metric', + uuid: metrics[0].uuid, + name: metrics[0].metric_name, + }, + ], + }, + ], + }, + { + name: 'Second test folder', + type: 'folder', + uuid: '2', + children: [ + { + type: 'column', + uuid: columns[0].uuid, + name: columns[0].column_name, + }, + { + type: 'column', + uuid: columns[1].uuid, + name: columns[1].column_name, + }, + ], + }, + ], +}; + const mockUser = { createdOn: '2021-04-27T18:12:38.952304', email: 'admin', @@ -90,6 +132,18 @@ const props: DatasourcePanelProps = { width: 300, }; +const propsWithFolders = { + ...props, + datasource: datasourceWithFolders, + controls: { + ...props.controls, + datasource: { + ...props.controls.datasource, + datasource: datasourceWithFolders, + }, + }, +}; + const metricProps = { savedMetrics: [], columns: [], @@ -125,13 +179,9 @@ test('should render the metrics', async () => { , { useRedux: true, useDnd: true }, ); - const metricsNum = metrics.length; metrics.forEach(metric => expect(screen.getByText(metric.metric_name)).toBeInTheDocument(), ); - expect( - await screen.findByText(`Showing ${metricsNum} of ${metricsNum}`), - ).toBeInTheDocument(); }); test('should render the columns', async () => { @@ -142,13 +192,9 @@ test('should render the columns', async () => { , { useRedux: true, useDnd: true }, ); - const columnsNum = columns.length; columns.forEach(col => expect(screen.getByText(col.column_name)).toBeInTheDocument(), ); - expect( - await screen.findByText(`Showing ${columnsNum} of ${columnsNum}`), - ).toBeInTheDocument(); }); describe('DatasourcePanel', () => { @@ -310,3 +356,139 @@ test('should render only droppable metrics and columns', async () => { unmount(); }); + +test('Renders with custom folders', () => { + render( + + + + , + { + useRedux: true, + useDnd: true, + }, + ); + + expect(screen.getByText('Test folder')).toBeInTheDocument(); + expect(screen.getByText('Test nested folder')).toBeInTheDocument(); + expect(screen.getByText('Second test folder')).toBeInTheDocument(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + + columns.forEach(col => { + expect(screen.getByText(col.column_name)).toBeInTheDocument(); + }); + + metrics.forEach(metric => { + expect(screen.getByText(metric.metric_name)).toBeInTheDocument(); + }); + + expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5); + expect(screen.getAllByTestId('datasource-panel-divider').length).toEqual(3); +}); + +test('Collapse folders', () => { + render( + + + + , + { + useRedux: true, + useDnd: true, + }, + ); + + userEvent.click(screen.getByText('Test folder')); + + expect(screen.getByText('Test folder')).toBeInTheDocument(); + expect(screen.queryByText('Test nested folder')).not.toBeInTheDocument(); + expect(screen.getByText('Second test folder')).toBeInTheDocument(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + + expect(screen.queryByText(metrics[0].metric_name)).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Test folder')); + + expect(screen.getByText('Test folder')).toBeInTheDocument(); + expect(screen.getByText('Test nested folder')).toBeInTheDocument(); + expect(screen.getByText('Second test folder')).toBeInTheDocument(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('Columns')).toBeInTheDocument(); + + expect(screen.getByText(metrics[0].metric_name)).toBeInTheDocument(); +}); + +test('Default Metrics and Columns folders dont render when all metrics and columns are assigned to custom folders', () => { + const datasourceWithFullFolders: IDatasource = { + ...datasource, + folders: [ + { + name: 'Test folder', + type: 'folder', + uuid: '1', + children: [ + { + name: 'Test nested folder', + type: 'folder', + uuid: '1.1', + children: metrics.map(m => ({ + type: 'metric' as const, + uuid: m.uuid, + name: m.metric_name, + })), + }, + ], + }, + { + name: 'Second test folder', + type: 'folder', + uuid: '2', + children: columns.map(c => ({ + type: 'column', + uuid: c.uuid, + name: c.column_name, + })), + }, + ], + }; + const propsWithFullFolders = { + ...props, + datasource: datasourceWithFullFolders, + controls: { + ...props.controls, + datasource: { + ...props.controls.datasource, + datasource: datasourceWithFullFolders, + }, + }, + }; + render( + + + + , + { + useRedux: true, + useDnd: true, + }, + ); + + expect(screen.getByText('Test folder')).toBeInTheDocument(); + expect(screen.getByText('Test nested folder')).toBeInTheDocument(); + expect(screen.getByText('Second test folder')).toBeInTheDocument(); + expect(screen.queryByText('Metrics')).not.toBeInTheDocument(); + expect(screen.queryByText('Columns')).not.toBeInTheDocument(); + + columns.forEach(col => { + expect(screen.getByText(col.column_name)).toBeInTheDocument(); + }); + + metrics.forEach(metric => { + expect(screen.getByText(metric.metric_name)).toBeInTheDocument(); + }); + + expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5); + expect(screen.getAllByTestId('datasource-panel-divider').length).toEqual(1); +}); diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx index cced2869c94..12fd816b6db 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx @@ -23,7 +23,7 @@ import DatasourcePanelDragOption from '.'; test('should render', async () => { render( , { useDnd: true }, @@ -38,7 +38,7 @@ test('should render', async () => { test('should have attribute draggable:true', async () => { render( , { useDnd: true }, diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx index 3ec64bf3266..b96905ba2eb 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx @@ -20,178 +20,89 @@ import { columns, metrics, } from 'src/explore/components/DatasourcePanel/fixtures'; -import { fireEvent, render, within } from 'spec/helpers/testing-library'; -import DatasourcePanelItem from './DatasourcePanelItem'; +import { screen, userEvent, render } from 'spec/helpers/testing-library'; +import DatasourcePanelItem, { + DatasourcePanelItemProps, +} from './DatasourcePanelItem'; -const mockData = { - metricSlice: metrics, - columnSlice: columns, - totalMetrics: Math.max(metrics.length, 10), - totalColumns: Math.max(columns.length, 13), +const mockData: DatasourcePanelItemProps['data'] = { + flattenedItems: [ + { type: 'header', depth: 0, folderId: '1', height: 50 }, + ...metrics.map((m, idx) => ({ + type: 'item' as const, + depth: 0, + folderId: '1', + height: 32, + index: idx, + item: { ...m, type: 'metric' as const }, + })), + { type: 'divider', depth: 0, folderId: '1', height: 16 }, + { type: 'header', depth: 0, folderId: '2', height: 50 }, + ...columns.map((m, idx) => ({ + type: 'item' as const, + depth: 0, + folderId: '2', + height: 32, + index: idx, + item: { ...m, type: 'column' as const }, + })), + ], + folderMap: new Map([ + [ + '1', + { + id: '1', + isCollapsed: false, + name: 'Metrics', + items: metrics.map(m => ({ ...m, type: 'metric' })), + totalItems: metrics.length, + showingItems: metrics.length, + }, + ], + [ + '2', + { + id: '2', + isCollapsed: false, + name: 'Columns', + items: columns.map(c => ({ ...c, type: 'column' })), + totalItems: columns.length, + showingItems: columns.length, + }, + ], + ]), width: 300, - showAllMetrics: false, - onShowAllMetricsChange: jest.fn(), - showAllColumns: false, - onShowAllColumnsChange: jest.fn(), - collapseMetrics: false, - onCollapseMetricsChange: jest.fn(), - collapseColumns: false, - onCollapseColumnsChange: jest.fn(), - hiddenMetricCount: 0, - hiddenColumnCount: 0, + onToggleCollapse: jest.fn(), + collapsedFolderIds: new Set(), }; -test('renders each item accordingly', () => { - const { getByText, getByTestId, rerender, container } = render( - , +const setup = (data: DatasourcePanelItemProps['data'] = mockData) => + render( + <> + {data.flattenedItems.map((_, index) => ( + + ))} + , { useDnd: true }, ); - expect(getByText('Metrics')).toBeInTheDocument(); - rerender(); - expect( - getByText( - `Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`, - ), - ).toBeInTheDocument(); - mockData.metricSlice.forEach((metric, metricIndex) => { - rerender( - , - ); - expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument(); - expect( - within(getByTestId('DatasourcePanelDragOption')).getByText( - metric.metric_name, - ), - ).toBeInTheDocument(); - }); - rerender( - , - ); - expect(container).toHaveTextContent(''); +test('renders each item accordingly', () => { + setup(); + expect(screen.getByText('Metrics')).toBeInTheDocument(); + expect(screen.getByText('metric_end_certified')).toBeInTheDocument(); + expect(screen.getByText('metric_end')).toBeInTheDocument(); - const startIndexOfColumnSection = mockData.metricSlice.length + 3; - rerender( - , - ); - expect(getByText('Columns')).toBeInTheDocument(); - rerender( - , - ); - expect( - getByText( - `Showing ${mockData.columnSlice.length} of ${mockData.totalColumns}`, - ), - ).toBeInTheDocument(); - mockData.columnSlice.forEach((column, columnIndex) => { - rerender( - , - ); - expect(getByTestId('DatasourcePanelDragOption')).toBeInTheDocument(); - expect( - within(getByTestId('DatasourcePanelDragOption')).getByText( - column.column_name, - ), - ).toBeInTheDocument(); - }); + expect(screen.getByText('Columns')).toBeInTheDocument(); + expect(screen.getByText('bootcamp_attend')).toBeInTheDocument(); + expect(screen.getByText('calc_first_time_dev')).toBeInTheDocument(); + expect(screen.getByText('aaaaaaaaaaa')).toBeInTheDocument(); + + expect(screen.getByTestId('datasource-panel-divider')).toBeInTheDocument(); + expect(screen.getAllByTestId('DatasourcePanelDragOption').length).toEqual(5); }); test('can collapse metrics and columns', () => { - mockData.onCollapseMetricsChange.mockClear(); - mockData.onCollapseColumnsChange.mockClear(); - const { queryByText, getByRole, rerender } = render( - , - { useDnd: true }, - ); - fireEvent.click(getByRole('button')); - expect(mockData.onCollapseMetricsChange).toHaveBeenCalled(); - expect(mockData.onCollapseColumnsChange).not.toHaveBeenCalled(); - - const startIndexOfColumnSection = mockData.metricSlice.length + 3; - rerender( - , - ); - fireEvent.click(getByRole('button')); - expect(mockData.onCollapseColumnsChange).toHaveBeenCalled(); - - rerender( - , - ); - expect( - queryByText( - `Showing ${mockData.metricSlice.length} of ${mockData.totalMetrics}`, - ), - ).not.toBeInTheDocument(); - - rerender( - , - ); - expect(queryByText('Columns')).toBeInTheDocument(); -}); - -test('shows ineligible items count', () => { - const hiddenColumnCount = 3; - const hiddenMetricCount = 1; - const dataWithHiddenItems = { - ...mockData, - hiddenColumnCount, - hiddenMetricCount, - }; - const { getByText, rerender } = render( - , - { useDnd: true }, - ); - expect( - getByText(`${hiddenMetricCount} ineligible item(s) are hidden`), - ).toBeInTheDocument(); - - const startIndexOfColumnSection = mockData.metricSlice.length + 3; - rerender( - , - ); - expect( - getByText(`${hiddenColumnCount} ineligible item(s) are hidden`), - ).toBeInTheDocument(); + setup(); + userEvent.click(screen.getAllByRole('button')[0]); + expect(mockData.onToggleCollapse).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx index 0ca8f92e161..7f7830acc05 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx @@ -16,71 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -import { CSSProperties, FC } from 'react'; +import { CSSProperties, ReactNode, useCallback } from 'react'; -import { css, Metric, styled, t, useTheme } from '@superset-ui/core'; +import { + css, + styled, + t, + useCSSTextTruncation, + useTheme, +} from '@superset-ui/core'; import { Icons } from 'src/components/Icons'; +import { Tooltip } from 'src/components/Tooltip'; import DatasourcePanelDragOption from './DatasourcePanelDragOption'; import { DndItemType } from '../DndItemType'; -import { DndItemValue } from './types'; - -export type DataSourcePanelColumn = { - is_dttm?: boolean | null; - description?: string | null; - expression?: string | null; - is_certified?: number | null; - column_name?: string | null; - name?: string | null; - type?: string; -}; - -type Props = { - index: number; - style: CSSProperties; - data: { - metricSlice: Metric[]; - columnSlice: DataSourcePanelColumn[]; - totalMetrics: number; - totalColumns: number; - width: number; - showAllMetrics: boolean; - onShowAllMetricsChange: (showAll: boolean) => void; - showAllColumns: boolean; - onShowAllColumnsChange: (showAll: boolean) => void; - collapseMetrics: boolean; - onCollapseMetricsChange: (collapse: boolean) => void; - collapseColumns: boolean; - onCollapseColumnsChange: (collapse: boolean) => void; - hiddenMetricCount: number; - hiddenColumnCount: number; - }; -}; - -export const DEFAULT_MAX_COLUMNS_LENGTH = 50; -export const DEFAULT_MAX_METRICS_LENGTH = 50; -export const ITEM_HEIGHT = 30; - -const Button = styled.button` - background: none; - border: none; - text-decoration: underline; - color: ${({ theme }) => theme.colors.primary.dark1}; -`; - -const ButtonContainer = styled.div` - text-align: center; - padding-top: 2px; -`; +import { DndItemValue, FlattenedItem, Folder } from './types'; const LabelWrapper = styled.div` ${({ theme }) => css` + color: ${theme.colors.grayscale.dark1}; overflow: hidden; text-overflow: ellipsis; font-size: ${theme.typography.sizes.s}px; background-color: ${theme.colors.grayscale.light4}; margin: ${theme.gridUnit * 2}px 0; - border-radius: 4px; + border-radius: ${theme.borderRadius}px; padding: 0 ${theme.gridUnit}px; &:first-of-type { @@ -117,98 +77,136 @@ const LabelWrapper = styled.div` `; const SectionHeaderButton = styled.button` - display: flex; - justify-content: space-between; - align-items: center; border: none; background: transparent; width: 100%; - padding-inline: 0px; + height: 100%; + padding-inline: 0; +`; + +const SectionHeaderTextContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; `; const SectionHeader = styled.span` - ${({ theme }) => ` + ${({ theme }) => css` + color: ${theme.colors.grayscale.dark1}; font-size: ${theme.typography.sizes.m}px; + font-weight: ${theme.typography.weights.medium}; line-height: 1.3; - `} -`; - -const Box = styled.div` - ${({ theme }) => ` - border: 1px ${theme.colors.grayscale.light4} solid; - border-radius: ${theme.gridUnit}px; - font-size: ${theme.typography.sizes.s}px; - padding: ${theme.gridUnit}px; - color: ${theme.colors.grayscale.light1}; - text-overflow: ellipsis; - white-space: nowrap; + text-align: left; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; overflow: hidden; + text-overflow: ellipsis; `} `; -const DatasourcePanelItem: FC = ({ index, style, data }) => { - const { - metricSlice: _metricSlice, - columnSlice, - totalMetrics, - totalColumns, - width, - showAllMetrics, - onShowAllMetricsChange, - showAllColumns, - onShowAllColumnsChange, - collapseMetrics, - onCollapseMetricsChange, - collapseColumns, - onCollapseColumnsChange, - hiddenMetricCount, - hiddenColumnCount, - } = data; - const metricSlice = collapseMetrics ? [] : _metricSlice; +const Divider = styled.div` + ${({ theme }) => css` + height: 16px; + border-bottom: 1px solid ${theme.colors.grayscale.light3}; + `} +`; - const EXTRA_LINES = collapseMetrics ? 1 : 2; - const isColumnSection = collapseMetrics - ? index >= 1 - : index > metricSlice.length + EXTRA_LINES; - const HEADER_LINE = isColumnSection - ? metricSlice.length + EXTRA_LINES + 1 - : 0; - const SUBTITLE_LINE = HEADER_LINE + 1; - const BOTTOM_LINE = - (isColumnSection ? columnSlice.length : metricSlice.length) + - (collapseMetrics ? HEADER_LINE : SUBTITLE_LINE) + - 1; - const collapsed = isColumnSection ? collapseColumns : collapseMetrics; - const setCollapse = isColumnSection - ? onCollapseColumnsChange - : onCollapseMetricsChange; - const showAll = isColumnSection ? showAllColumns : showAllMetrics; - const setShowAll = isColumnSection - ? onShowAllColumnsChange - : onShowAllMetricsChange; +export interface DatasourcePanelItemProps { + index: number; + style: CSSProperties; + data: { + flattenedItems: FlattenedItem[]; + folderMap: Map; + width: number; + onToggleCollapse: (folderId: string) => void; + collapsedFolderIds: Set; + }; +} + +const DatasourcePanelItem = ({ + index, + style, + data, +}: DatasourcePanelItemProps) => { + const { + flattenedItems, + folderMap, + width, + onToggleCollapse, + collapsedFolderIds, + } = data; + const item = flattenedItems[index]; const theme = useTheme(); - const hiddenCount = isColumnSection ? hiddenColumnCount : hiddenMetricCount; + const [labelRef, labelIsTruncated] = useCSSTextTruncation({ + isVertical: true, + isHorizontal: false, + }); + + const getTooltipNode = useCallback( + (folder: Folder) => { + let tooltipNode: ReactNode | null = null; + if (labelIsTruncated) { + tooltipNode = ( +
+ {t('Name')}: {folder.name} +
+ ); + } + if (folder.description) { + tooltipNode = ( +
+ {tooltipNode} +
+ {t('Description')}: {folder.description} +
+
+ ); + } + return tooltipNode; + }, + [labelIsTruncated], + ); + + if (!item) return null; + + const folder = folderMap.get(item.folderId); + if (!folder) return null; + + const indentation = item.depth * theme.gridUnit * 4; return (
- {index === HEADER_LINE && ( - setCollapse(!collapsed)}> - - {isColumnSection ? t('Columns') : t('Metrics')} - - {collapsed ? ( - - ) : ( - - )} + {item.type === 'header' && ( + onToggleCollapse(folder.id)}> + + + {folder.name} + {collapsedFolderIds.has(folder.id) ? ( + + ) : ( + + )} + + )} - {index === SUBTITLE_LINE && !collapsed && ( + + {item.type === 'subtitle' && (
= ({ index, style, data }) => { flex-shrink: 0; `} > - {isColumnSection - ? t(`Showing %s of %s`, columnSlice?.length, totalColumns) - : t(`Showing %s of %s`, metricSlice?.length, totalMetrics)} + {t(`Showing %s of %s items`, item.showingItems, item.totalItems)}
- {hiddenCount > 0 && ( - {t(`%s ineligible item(s) are hidden`, hiddenCount)} - )}
)} - {index > SUBTITLE_LINE && index < BOTTOM_LINE && ( + + {item.type === 'item' && item.item && ( )} - {index === BOTTOM_LINE && - !collapsed && - (isColumnSection - ? totalColumns > DEFAULT_MAX_COLUMNS_LENGTH - : totalMetrics > DEFAULT_MAX_METRICS_LENGTH) && ( - - - - )} + + {item.type === 'divider' && ( + + )} ); }; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/fixtures.tsx b/superset-frontend/src/explore/components/DatasourcePanel/fixtures.tsx index 1840ad7a67a..0e572ad002a 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/fixtures.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/fixtures.tsx @@ -26,6 +26,7 @@ export const columns = [ filterable: true, groupby: true, id: 516, + uuid: '516', is_dttm: false, python_date_format: null, type: 'DOUBLE', @@ -40,6 +41,7 @@ export const columns = [ filterable: true, groupby: true, id: 477, + uuid: '477', is_dttm: false, python_date_format: null, type: 'VARCHAR', @@ -52,7 +54,8 @@ export const columns = [ expression: null, filterable: true, groupby: true, - id: 516, + id: 517, + uuid: '517', is_dttm: false, python_date_format: null, type: 'INT', @@ -70,6 +73,7 @@ const metricsFiltered = { description: null, expression: '', id: 56, + uuid: '56', is_certified: true, metric_name: 'metric_end_certified', verbose_name: '', @@ -84,6 +88,7 @@ const metricsFiltered = { description: null, expression: '', id: 57, + uuid: '57', is_certified: false, metric_name: 'metric_end', verbose_name: '', diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx index 30c53ffd2d1..e1bb482a5e4 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx @@ -28,7 +28,6 @@ import { import { ControlConfig } from '@superset-ui/chart-controls'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeList as List } from 'react-window'; import { matchSorter, rankings } from 'match-sorter'; import Alert from 'src/components/Alert'; @@ -39,22 +38,19 @@ import { FAST_DEBOUNCE } from 'src/constants'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import Control from 'src/explore/components/Control'; import { useDebounceValue } from 'src/hooks/useDebounceValue'; -import DatasourcePanelItem, { - ITEM_HEIGHT, - DataSourcePanelColumn, - DEFAULT_MAX_COLUMNS_LENGTH, - DEFAULT_MAX_METRICS_LENGTH, -} from './DatasourcePanelItem'; import { DndItemType } from '../DndItemType'; -import { DndItemValue } from './types'; +import { DatasourceFolder, DatasourcePanelColumn, DndItemValue } from './types'; import { DropzoneContext } from '../ExploreContainer'; +import { DatasourceItems } from './DatasourceItems'; +import { transformDatasourceWithFolders } from './transformDatasourceFolders'; interface DatasourceControl extends Omit { datasource?: IDatasource; } export interface IDatasource { metrics: Metric[]; - columns: DataSourcePanelColumn[]; + columns: DatasourcePanelColumn[]; + folders?: DatasourceFolder[]; id: number; type: DatasourceType; database: { @@ -126,8 +122,18 @@ const StyledInfoboxWrapper = styled.div` const BORDER_WIDTH = 2; -const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) => - slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0)); +const sortColumns = (slice: DatasourcePanelColumn[]) => + [...slice] + .sort((col1, col2) => { + if (col1?.is_dttm && !col2?.is_dttm) { + return -1; + } + if (col2?.is_dttm && !col1?.is_dttm) { + return 1; + } + return 0; + }) + .sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0)); export default function DataSourcePanel({ datasource, @@ -137,7 +143,7 @@ export default function DataSourcePanel({ width, }: Props) { const [dropzones] = useContext(DropzoneContext); - const { columns: _columns, metrics } = datasource; + const { columns: _columns, metrics, folders: _folders } = datasource; const allowedColumns = useMemo(() => { const validators = Object.values(dropzones); @@ -152,21 +158,6 @@ export default function DataSourcePanel({ ); }, [dropzones, _columns]); - // display temporal column first - const columns = useMemo( - () => - [...allowedColumns].sort((col1, col2) => { - if (col1?.is_dttm && !col2?.is_dttm) { - return -1; - } - if (col2?.is_dttm && !col1?.is_dttm) { - return 1; - } - return 0; - }), - [allowedColumns], - ); - const allowedMetrics = useMemo(() => { const validators = Object.values(dropzones); return metrics.filter(metric => @@ -176,21 +167,15 @@ export default function DataSourcePanel({ ); }, [dropzones, metrics]); - const hiddenColumnCount = _columns.length - allowedColumns.length; - const hiddenMetricCount = metrics.length - allowedMetrics.length; const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false); const [inputValue, setInputValue] = useState(''); - const [showAllMetrics, setShowAllMetrics] = useState(false); - const [showAllColumns, setShowAllColumns] = useState(false); - const [collapseMetrics, setCollapseMetrics] = useState(false); - const [collapseColumns, setCollapseColumns] = useState(false); const searchKeyword = useDebounceValue(inputValue, FAST_DEBOUNCE); const filteredColumns = useMemo(() => { if (!searchKeyword) { - return columns ?? []; + return allowedColumns ?? []; } - return matchSorter(columns, searchKeyword, { + return matchSorter(allowedColumns, searchKeyword, { keys: [ { key: 'verbose_name', @@ -211,7 +196,7 @@ export default function DataSourcePanel({ ], keepDiacritics: true, }); - }, [columns, searchKeyword]); + }, [allowedColumns, searchKeyword]); const filteredMetrics = useMemo(() => { if (!searchKeyword) { @@ -244,22 +229,21 @@ export default function DataSourcePanel({ }); }, [allowedMetrics, searchKeyword]); - const metricSlice = useMemo( - () => - showAllMetrics - ? filteredMetrics - : filteredMetrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH), - [filteredMetrics, showAllMetrics], + const sortedColumns = useMemo( + () => sortColumns(filteredColumns), + [filteredColumns], ); - const columnSlice = useMemo( + const folders = useMemo( () => - showAllColumns - ? sortCertifiedFirst(filteredColumns) - : sortCertifiedFirst( - filteredColumns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH), - ), - [filteredColumns, showAllColumns], + transformDatasourceWithFolders( + filteredMetrics, + sortedColumns, + _folders, + allowedMetrics, + allowedColumns, + ), + [_folders, filteredMetrics, sortedColumns], ); const showInfoboxCheck = () => { @@ -324,57 +308,17 @@ export default function DataSourcePanel({ )} {({ height }: { height: number }) => ( - - {DatasourcePanelItem} - + folders={folders} + /> )} ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - columnSlice, - inputValue, - filteredColumns.length, - filteredMetrics.length, - metricSlice, - showAllColumns, - showAllMetrics, - collapseMetrics, - collapseColumns, - datasourceIsSaveable, - width, - ], + [inputValue, datasourceIsSaveable, width, folders], ); return ( diff --git a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts new file mode 100644 index 00000000000..6099b72c1ea --- /dev/null +++ b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.test.ts @@ -0,0 +1,208 @@ +/** + * 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/core'; +import { transformDatasourceWithFolders } from './transformDatasourceFolders'; +import { DatasourceFolder, DatasourcePanelColumn } from './types'; + +const mockMetrics: Metric[] = [ + { metric_name: 'metric1', uuid: 'metric1-uuid', expression: 'SUM(col1)' }, + { metric_name: 'metric2', uuid: 'metric2-uuid', expression: 'AVG(col2)' }, + { metric_name: 'metric3', uuid: 'metric3-uuid', expression: 'COUNT(*)' }, +]; + +const mockColumns: DatasourcePanelColumn[] = [ + { column_name: 'column1', uuid: 'column1-uuid', type: 'STRING' }, + { column_name: 'column2', uuid: 'column2-uuid', type: 'NUMERIC' }, + { column_name: 'column3', uuid: 'column3-uuid', type: 'DATETIME' }, +]; + +test('transforms data into default folders when no folder config is provided', () => { + const result = transformDatasourceWithFolders( + mockMetrics, + mockColumns, + undefined, + mockMetrics, + mockColumns, + ); + + expect(result).toHaveLength(2); + + expect(result[0].id).toBe('metrics-default'); + 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[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'); +}); + +test('transforms data according to folder configuration', () => { + const folderConfig: DatasourceFolder[] = [ + { + uuid: 'folder1', + type: 'folder', + name: 'Important Metrics', + description: 'Key metrics folder', + children: [ + { type: 'metric', uuid: 'metric1-uuid', name: 'metric1' }, + { type: 'metric', uuid: 'metric2-uuid', name: 'metric2' }, + ], + }, + { + uuid: 'folder2', + type: 'folder', + name: 'Key Dimensions', + children: [{ type: 'column', uuid: 'column1-uuid', name: 'column1' }], + }, + ]; + + const result = transformDatasourceWithFolders( + mockMetrics, + mockColumns, + folderConfig, + mockMetrics, + mockColumns, + ); + + // We expect 4 folders: + // 1. Important Metrics (from config) + // 2. Key Dimensions (from config) + // 3. Metrics (default for unassigned metrics) + // 4. Columns (default for unassigned columns) + expect(result).toHaveLength(4); + + expect(result[0].id).toBe('folder1'); + expect(result[0].name).toBe('Important Metrics'); + expect(result[0].description).toBe('Key metrics folder'); + expect(result[0].items).toHaveLength(2); + expect(result[0].items[0].uuid).toBe('metric1-uuid'); + + expect(result[1].id).toBe('folder2'); + expect(result[1].name).toBe('Key Dimensions'); + expect(result[1].items).toHaveLength(1); + expect(result[1].items[0].uuid).toBe('column1-uuid'); + + expect(result[2].id).toBe('metrics-default'); + expect(result[2].items).toHaveLength(1); + expect(result[2].items[0].uuid).toBe('metric3-uuid'); + + expect(result[3].id).toBe('columns-default'); + expect(result[3].items).toHaveLength(2); +}); + +test('handles nested folder structures', () => { + const folderConfig: DatasourceFolder[] = [ + { + uuid: 'parent-folder', + type: 'folder', + name: 'Parent Folder', + children: [ + { + uuid: 'child-folder', + type: 'folder', + name: 'Child Folder', + children: [{ type: 'metric', uuid: 'metric1-uuid', name: 'metric1' }], + }, + { type: 'column', uuid: 'column1-uuid', name: 'column1' }, + ], + }, + ]; + + const result = transformDatasourceWithFolders( + mockMetrics, + mockColumns, + folderConfig, + mockMetrics, + mockColumns, + ); + + expect(result[0].id).toBe('parent-folder'); + expect(result[0].name).toBe('Parent Folder'); + expect(result[0].items).toHaveLength(1); + expect(result[0].subFolders).toHaveLength(1); + + const childFolder = result[0].subFolders![0]; + expect(childFolder.id).toBe('child-folder'); + expect(childFolder.name).toBe('Child Folder'); + expect(childFolder.items).toHaveLength(1); + expect(childFolder.parentId).toBe('parent-folder'); +}); + +test('handles empty children arrays', () => { + const folderConfig: DatasourceFolder[] = [ + { + uuid: 'empty-folder', + type: 'folder', + name: 'Empty Folder', + children: [], + }, + ]; + + const result = transformDatasourceWithFolders( + mockMetrics, + mockColumns, + folderConfig, + mockMetrics, + mockColumns, + ); + + expect(result[0].id).toBe('empty-folder'); + expect(result[0].name).toBe('Empty Folder'); + expect(result[0].items).toHaveLength(0); +}); + +test('handles non-existent metric and column UUIDs in folder config', () => { + const folderConfig: DatasourceFolder[] = [ + { + uuid: 'folder1', + type: 'folder', + name: 'Test Folder', + children: [ + { + type: 'metric', + uuid: 'non-existent-metric', + name: 'Missing Metric', + }, + { + type: 'column', + uuid: 'non-existent-column', + name: 'Missing Column', + }, + { type: 'metric', uuid: 'metric1-uuid', name: 'Existing Metric' }, + ], + }, + ]; + + const result = transformDatasourceWithFolders( + mockMetrics, + mockColumns, + folderConfig, + mockMetrics, + mockColumns, + ); + + expect(result[0].id).toBe('folder1'); + expect(result[0].items).toHaveLength(1); + expect(result[0].items[0].uuid).toBe('metric1-uuid'); +}); diff --git a/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts new file mode 100644 index 00000000000..dd7368b9dbd --- /dev/null +++ b/superset-frontend/src/explore/components/DatasourcePanel/transformDatasourceFolders.ts @@ -0,0 +1,177 @@ +/** + * 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, t } from '@superset-ui/core'; +import { + ColumnItem, + DatasourceFolder, + DatasourcePanelColumn, + Folder, + MetricItem, +} from './types'; + +const transformToFolderStructure = ( + metricsToDisplay: MetricItem[], + columnsToDisplay: ColumnItem[], + folderConfig: DatasourceFolder[] | undefined, + allMetrics: Metric[], + allColumns: DatasourcePanelColumn[], +): Folder[] => { + const metricsMap = new Map(); + const columnsMap = new Map(); + + metricsToDisplay.forEach(metric => { + metricsMap.set(metric.uuid, metric); + }); + + columnsToDisplay.forEach(column => { + columnsMap.set(column.uuid, column); + }); + + let metricsInFolders = 0; + let columnsInFolders = 0; + const processFolder = ( + datasourceFolder: DatasourceFolder, + parentId?: string, + ): Folder => { + const folder: Folder = { + id: datasourceFolder.uuid, + name: datasourceFolder.name, + description: datasourceFolder.description, + isCollapsed: false, + items: [], + totalItems: 0, + showingItems: 0, + parentId, + }; + + if (datasourceFolder.children && datasourceFolder.children.length > 0) { + if (!folder.subFolders) { + folder.subFolders = []; + } + + datasourceFolder.children.forEach(child => { + if (child.type === 'folder') { + const subFolder = processFolder(child as DatasourceFolder, folder.id); + folder.subFolders!.push(subFolder); + folder.totalItems += subFolder.totalItems; + folder.showingItems += subFolder.showingItems; + } else if (child.type === 'metric') { + folder.totalItems += 1; + metricsInFolders += 1; + const metric = metricsMap.get(child.uuid); + if (metric) { + folder.items.push(metric); + metricsMap.delete(metric.uuid); + folder.showingItems += 1; + } + } else if (child.type === 'column') { + folder.totalItems += 1; + columnsInFolders += 1; + const column = columnsMap.get(child.uuid); + if (column) { + folder.items.push(column); + columnsMap.delete(column.uuid); + folder.showingItems += 1; + } + } + }); + } + + return folder; + }; + + if (!folderConfig) { + return [ + { + id: 'metrics-default', + name: t('Metrics'), + isCollapsed: false, + items: metricsToDisplay, + totalItems: allMetrics.length, + showingItems: metricsToDisplay.length, + }, + { + id: 'columns-default', + name: t('Columns'), + isCollapsed: false, + items: columnsToDisplay, + totalItems: allColumns.length, + showingItems: columnsToDisplay.length, + }, + ]; + } + + const folders = folderConfig.map(config => processFolder(config)); + + const unassignedMetrics = metricsToDisplay.filter(metric => + metricsMap.has(metric.uuid), + ); + const unassignedColumns = columnsToDisplay.filter(column => + columnsMap.has(column.uuid), + ); + + if (unassignedMetrics.length > 0) { + folders.push({ + id: 'metrics-default', + name: t('Metrics'), + isCollapsed: false, + items: unassignedMetrics, + totalItems: allMetrics.length - metricsInFolders, + showingItems: unassignedMetrics.length, + }); + } + + if (unassignedColumns.length > 0) { + folders.push({ + id: 'columns-default', + name: t('Columns'), + isCollapsed: false, + items: unassignedColumns, + totalItems: allColumns.length - columnsInFolders, + showingItems: unassignedColumns.length, + }); + } + + return folders; +}; + +export const transformDatasourceWithFolders = ( + metricsToDisplay: Metric[], + columnsToDisplay: DatasourcePanelColumn[], + folderConfig: DatasourceFolder[] | undefined, + allMetrics: Metric[], + allColumns: DatasourcePanelColumn[], +): Folder[] => { + const metricsWithType: MetricItem[] = metricsToDisplay.map(metric => ({ + ...metric, + type: 'metric', + })); + const columnsWithType: ColumnItem[] = columnsToDisplay.map(column => ({ + ...column, + type: 'column', + })); + + return transformToFolderStructure( + metricsWithType, + columnsWithType, + folderConfig, + allMetrics, + allColumns, + ); +}; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/types.ts b/superset-frontend/src/explore/components/DatasourcePanel/types.ts index 315eba93473..0639f3c8278 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/types.ts +++ b/superset-frontend/src/explore/components/DatasourcePanel/types.ts @@ -35,3 +35,59 @@ export function isDatasourcePanelDndItem( export function isSavedMetric(item: any): item is Metric { return item?.metric_name; } + +export type DatasourcePanelColumn = { + uuid: string; + id?: number; + is_dttm?: boolean | null; + description?: string | null; + expression?: string | null; + is_certified?: number | null; + column_name?: string | null; + name?: string | null; + type?: string; +}; + +export type DatasourceFolder = { + uuid: string; + type: 'folder'; + name: string; + description?: string; + children?: ( + | DatasourceFolder + | { type: 'metric'; uuid: string; name: string } + | { type: 'column'; uuid: string; name: string } + )[]; +}; + +export type MetricItem = Metric & { + type: 'metric'; +}; + +export type ColumnItem = DatasourcePanelColumn & { + type: 'column'; +}; + +export type FolderItem = MetricItem | ColumnItem; + +export interface Folder { + id: string; + name: string; + description?: string; + isCollapsed: boolean; + items: FolderItem[]; + subFolders?: Folder[]; + parentId?: string; + totalItems: number; + showingItems: number; // items shown after filtering +} + +export interface FlattenedItem { + type: 'header' | 'item' | 'divider' | 'subtitle'; + folderId: string; + depth: number; + item?: FolderItem; + height: number; + totalItems?: number; + showingItems?: number; +} diff --git a/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx index 66468b4721f..c8bf92caa41 100644 --- a/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx +++ b/superset-frontend/src/explore/components/ExploreContainer/ExploreContainer.test.tsx @@ -76,7 +76,7 @@ test('should only propagate dragging state when dragging the panel option', () = const { container, getByText } = render( {setup({ @@ -377,11 +381,11 @@ describe('when disallow_adhoc_metrics is set', () => { type={DndItemType.Column} /> {setup({ diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx index 91307b4dde4..4323520b8ee 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx @@ -334,7 +334,7 @@ test('cannot drop a duplicated item', () => { const { getByTestId } = render( <> @@ -362,7 +362,7 @@ test('can drop a saved metric when disallow_adhoc_metrics', () => { const { getByTestId } = render( <> { const { getByTestId, getAllByTestId } = render( <> { id: 1, type: DatasourceType.Table, columns: [{ column_name: 'a' }], - metrics: [{ metric_name: 'first' }, { metric_name: 'second' }], + metrics: [ + { metric_name: 'first', uuid: '1' }, + { metric_name: 'second', uuid: '2' }, + ], column_formats: {}, currency_formats: {}, verbose_map: {}, diff --git a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts index df1afd69a0a..b26ba8c1b4a 100644 --- a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts +++ b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.test.ts @@ -33,7 +33,7 @@ const sampleDatasource: Dataset = { { column_name: 'sample_column_3' }, { column_name: 'sample_column_4' }, ], - metrics: [{ metric_name: 'saved_metric_2' }], + metrics: [{ metric_name: 'saved_metric_2', uuid: '1' }], column_formats: {}, currency_formats: {}, verbose_map: {}, diff --git a/superset-frontend/src/explore/fixtures.tsx b/superset-frontend/src/explore/fixtures.tsx index eb1d0d6d662..211519590e9 100644 --- a/superset-frontend/src/explore/fixtures.tsx +++ b/superset-frontend/src/explore/fixtures.tsx @@ -133,7 +133,10 @@ export const exploreInitialData: ExplorePageInitialData = { id: 8, type: DatasourceType.Table, columns: [{ column_name: 'a' }], - metrics: [{ metric_name: 'first' }, { metric_name: 'second' }], + metrics: [ + { metric_name: 'first', uuid: '1' }, + { metric_name: 'second', uuid: '2' }, + ], column_formats: {}, currency_formats: {}, verbose_map: {}, diff --git a/superset-frontend/src/features/datasets/types.ts b/superset-frontend/src/features/datasets/types.ts index e0afb076706..63f1678d799 100644 --- a/superset-frontend/src/features/datasets/types.ts +++ b/superset-frontend/src/features/datasets/types.ts @@ -41,7 +41,7 @@ export type ColumnObject = { type MetricObject = { id: number; - uuid: number; + uuid: string; expression?: string; description?: string; metric_name: string;