Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion superset-frontend/src/dashboard/components/SliceAdder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ function SliceAdder({
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
style={{ width, height }}
style={{ width, height, maxHeight: height }}
rowCount={filteredSlices.length}
rowHeight={DEFAULT_CELL_HEIGHT}
rowProps={listRowProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export const DatasourceItems = ({

return (
<List
style={{ width: width - BORDER_WIDTH, height }}
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
rowHeight={rowHeight}
rowCount={flattenedItems.length}
rowProps={rowProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,20 @@ test('should render', async () => {
).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();
});

test('is faded and not draggable when excluded by compatibleMetrics', async () => {
render(
<DatasourcePanelDragOption
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{
useDndKit: true,
useRedux: true,
initialState: { explore: { compatibleMetrics: ['other_metric'] } },
},
);

const option = await screen.findByTestId('DatasourcePanelDragOption');
expect(option).toHaveStyle({ cursor: 'not-allowed' });
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
*/
import { RefObject, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { useSelector } from 'react-redux';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { ColumnMeta } from '@superset-ui/chart-controls';
Expand All @@ -28,8 +27,8 @@ import {
StyledMetricOption,
} from 'src/explore/components/optionRenderers';
import { Icons } from '@superset-ui/core/components/Icons';
import { ExplorePageState } from 'src/explore/types';

import { isCompatibleItem, useDatasourceCompatibility } from '../compatibility';
import { DatasourcePanelDndItem } from '../types';

const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
Expand Down Expand Up @@ -75,30 +74,14 @@ export default function DatasourcePanelDragOption(
const { labelRef, showTooltip, type, value } = props;
const theme = useTheme();

// Read compatibility lists from Redux.
// `null` means no filtering is active (SQL datasets, or no selection yet).
const compatibleMetrics = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore.compatibleMetrics);
const compatibleDimensions = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore.compatibleDimensions);
const { compatibleMetrics, compatibleDimensions } =
useDatasourceCompatibility();

// An item is compatible when the list is null (no filter) or when its
// name explicitly appears in the list returned by the backend.
const isCompatible = useMemo(() => {
if (type === DndItemType.Metric) {
if (!compatibleMetrics) return true;
return compatibleMetrics.includes((value as Metric).metric_name);
}
if (type === DndItemType.Column) {
if (!compatibleDimensions) return true;
return compatibleDimensions.includes((value as ColumnMeta).column_name);
}
return true;
}, [type, value, compatibleMetrics, compatibleDimensions]);
const isCompatible = useMemo(
() =>
isCompatibleItem(type, value, compatibleMetrics, compatibleDimensions),
[type, value, compatibleMetrics, compatibleDimensions],
);

// Create a unique ID for this draggable item
const draggableId = useMemo(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useDraggable } from '@dnd-kit/core';
import {
columns,
metrics,
Expand All @@ -25,8 +26,17 @@ import DatasourcePanelItem, {
DatasourcePanelItemRowProps,
} from './DatasourcePanelItem';
import { FoldersEditorItemType } from 'src/components/Datasource/types';
import { DndItemType } from '../DndItemType';
import { MetricItem, ColumnItem } from './types';

jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDraggable: jest.fn(),
}));

const mockUseDraggable = useDraggable as jest.Mock;
const actualUseDraggable = jest.requireActual('@dnd-kit/core').useDraggable;

const mockData: DatasourcePanelItemRowProps = {
flattenedItems: [
{ type: 'header', depth: 0, folderId: '1', height: 50 },
Expand Down Expand Up @@ -82,7 +92,15 @@ const mockData: DatasourcePanelItemRowProps = {
collapsedFolderIds: new Set(),
};

const setup = (data: DatasourcePanelItemRowProps = mockData) =>
beforeEach(() => {
mockUseDraggable.mockReset();
mockUseDraggable.mockImplementation(actualUseDraggable);
});

const setup = (
data: DatasourcePanelItemRowProps = mockData,
initialState: Record<string, unknown> = { explore: {} },
) =>
render(
<>
{data.flattenedItems.map((_, index) => (
Expand All @@ -100,7 +118,7 @@ const setup = (data: DatasourcePanelItemRowProps = mockData) =>
/>
))}
</>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
{ useDnd: true, useRedux: true, initialState },
);

test('renders each item accordingly', () => {
Expand All @@ -123,3 +141,59 @@ test('can collapse metrics and columns', () => {
userEvent.click(screen.getAllByRole('button')[0]);
expect(mockData.onToggleCollapse).toHaveBeenCalled();
});

test('folder drag handle is a separate element from the collapse toggle', () => {
setup();

const toggleButtons = screen
.getAllByRole('button', { name: /Metrics/ })
.filter(el => el.tagName === 'BUTTON');
expect(toggleButtons).toHaveLength(1);
const [toggleButton] = toggleButtons;
const dragHandle = screen.getByRole('button', {
name: 'Drag Metrics folder',
});

expect(toggleButton).not.toBe(dragHandle);
expect(toggleButton.tagName).toBe('BUTTON');

userEvent.click(toggleButton);
expect(mockData.onToggleCollapse).toHaveBeenCalledWith('1');
});

test('folder drag payload excludes columns filtered out by compatibleDimensions', () => {
setup(mockData, {
explore: { compatibleDimensions: [columns[0].column_name] },
});

const folderHeaderCalls = mockUseDraggable.mock.calls.filter(
([opts]) => opts.data.type === DndItemType.Folder,
);
const columnsFolderCall = folderHeaderCalls.find(
([opts]) => opts.data.name === 'Columns',
);

expect(columnsFolderCall![0].data.items).toEqual([
expect.objectContaining({
type: DndItemType.Column,
value: expect.objectContaining({ column_name: columns[0].column_name }),
}),
]);
expect(columnsFolderCall![0].disabled).toBe(false);
});

test('folder header is not draggable when every item is filtered out', () => {
setup(mockData, {
explore: { compatibleDimensions: ['non-existent-column'] },
});

const folderHeaderCalls = mockUseDraggable.mock.calls.filter(
([opts]) => opts.data.type === DndItemType.Folder,
);
const columnsFolderCall = folderHeaderCalls.find(
([opts]) => opts.data.name === 'Columns',
);

expect(columnsFolderCall![0].data.items).toEqual([]);
expect(columnsFolderCall![0].disabled).toBe(true);
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useCallback } from 'react';
import type { RowComponentProps } from 'react-window';
import { ReactNode, useCallback, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';

import { t } from '@apache-superset/core/translation';
import { useCSSTextTruncation } from '@superset-ui/core';
Expand All @@ -28,6 +29,9 @@ import { Tooltip } from '@superset-ui/core/components/Tooltip';
import { Typography } from '@superset-ui/core/components';
import DatasourcePanelDragOption from './DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
import { useActiveDrag } from '../ExploreContainer/ExploreDndContext';
import { collectFolderDragItems, collectFolderIds } from './folderDrag';
import { isCompatibleItem, useDatasourceCompatibility } from './compatibility';
import { DndItemValue, FlattenedItem, Folder } from './types';

const LabelWrapper = styled.div`
Expand Down Expand Up @@ -77,14 +81,40 @@ const LabelWrapper = styled.div`
`}
`;

const SectionHeaderRow = styled.div`
display: flex;
align-items: center;
width: 100%;
height: 100%;
`;

const SectionHeaderButton = styled.button`
border: none;
background: transparent;
width: 100%;
flex: 1;
min-width: 0;
height: 100%;
padding-inline: 0;
`;

const FolderDragHandle = styled.div<{ isDraggable: boolean }>`
${({ theme, isDraggable }) => css`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: ${theme.sizeUnit * 6}px;
height: 100%;
cursor: ${isDraggable ? 'grab' : 'not-allowed'};
opacity: ${isDraggable ? 1 : 0.35};
color: ${theme.colorFill};

&:hover {
color: ${isDraggable ? theme.colorIcon : theme.colorFill};
}
`}
`;

const SectionHeaderTextContainer = styled.div`
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -172,9 +202,60 @@ const DatasourcePanelItem = ({
[labelIsTruncated],
);

if (!item) return null;
// Folder headers double as a drag source: dragging the label picks up every
// column/metric in the folder (and its subfolders). Hooks must run on every
// row regardless of type, so compute the folder up front and disable the
// draggable for non-header rows / empty folders.
const isFolderHeader = item?.type === 'header';
const folder = item ? folderMap.get(item.folderId) : undefined;
const { compatibleMetrics, compatibleDimensions } =
useDatasourceCompatibility();
const folderDragItems = useMemo(
() =>
isFolderHeader && folder
? collectFolderDragItems(folder).filter(({ type, value }) =>
isCompatibleItem(
type,
value,
compatibleMetrics,
compatibleDimensions,
),
)
: [],
[isFolderHeader, folder, compatibleMetrics, compatibleDimensions],
);
const folderDragIds = useMemo(
() => (isFolderHeader && folder ? collectFolderIds(folder) : []),
[isFolderHeader, folder],
);
const {
attributes: folderDragAttributes,
listeners: folderDragListeners,
setNodeRef: setFolderDragRef,
} = useDraggable({
// Keyed by the flattened row index so every row (header, item, divider…)
// gets a unique draggable id — a folder's header and its child rows would
// otherwise collide on the shared folder id.
id: `datasource-folder-row-${index}`,
data: {
type: DndItemType.Folder,
name: folder?.name,
items: folderDragItems,
folderIds: folderDragIds,
},
disabled: !isFolderHeader || folderDragItems.length === 0,
});

// Fade every row of the folder currently being dragged (header + its items,
// subtitle, divider, and any subfolder rows). Each flattened row carries its
// folder id, so a row is in flight when its id is in the drag's folderIds.
const activeDrag = useActiveDrag();
const isRowInDraggedFolder =
activeDrag?.type === DndItemType.Folder &&
!!item &&
!!activeDrag.folderIds?.includes(item.folderId);

const folder = folderMap.get(item.folderId);
if (!item) return null;
if (!folder) return null;

const indentation = item.depth * theme.sizeUnit * 4;
Expand All @@ -185,21 +266,36 @@ const DatasourcePanelItem = ({
...style,
paddingLeft: theme.sizeUnit * 4 + indentation,
paddingRight: theme.sizeUnit * 4,
opacity: isRowInDraggedFolder ? 0.5 : undefined,
}}
>
{item.type === 'header' && (
<SectionHeaderButton onClick={() => onToggleCollapse(folder.id)}>
<Tooltip title={getTooltipNode(folder)}>
<SectionHeaderTextContainer>
<SectionHeader ref={labelRef}>{folder.name}</SectionHeader>
{collapsedFolderIds.has(folder.id) ? (
<Icons.DownOutlined iconSize="s" iconColor={theme.colorText} />
) : (
<Icons.UpOutlined iconSize="s" iconColor={theme.colorText} />
)}
</SectionHeaderTextContainer>
</Tooltip>
</SectionHeaderButton>
<SectionHeaderRow>
<SectionHeaderButton onClick={() => onToggleCollapse(folder.id)}>
<Tooltip title={getTooltipNode(folder)}>
<SectionHeaderTextContainer>
<SectionHeader ref={labelRef}>{folder.name}</SectionHeader>
{collapsedFolderIds.has(folder.id) ? (
<Icons.DownOutlined
iconSize="s"
iconColor={theme.colorText}
/>
) : (
<Icons.UpOutlined iconSize="s" iconColor={theme.colorText} />
)}
</SectionHeaderTextContainer>
</Tooltip>
</SectionHeaderButton>
<FolderDragHandle
ref={setFolderDragRef}
isDraggable={folderDragItems.length > 0}
{...folderDragAttributes}
{...folderDragListeners}
aria-label={t('Drag %s folder', folder.name)}
>
<Icons.Drag iconSize="xl" />
</FolderDragHandle>
</SectionHeaderRow>
)}

{item.type === 'subtitle' && (
Expand Down
Loading
Loading