Compare commits

..

7 Commits

Author SHA1 Message Date
Amin Ghadersohi
30ebc94b2f Merge branch 'master' into mcp-chart-explore-url 2026-06-06 15:17:13 -07:00
Amin Ghadersohi
053f1292a4 fix(mcp): preserve form_data_key URL for preview callers of generate_explore_link
Returning a durable permalink URL by default broke callers that re-parse the
URL for a form_data_key: update_chart_preview failed its success path with
"missing form_data_key" and generate_chart preview mode lost its unsaved-state
key. Add a prefer_permalink flag (default True for the explore tool) and have
the two preview callers pass prefer_permalink=False to keep the
/explore/?form_data_key=... contract. Add a regression test.
2026-06-06 22:14:02 +00:00
Amin Ghadersohi
8b85cdbd4a test(mcp): mock permalink command in form_data_key fallback test
After narrowing the explore-link exception handling, the durable-permalink
path no longer swallows the AttributeError raised by check_chart_access
accessing g.user outside a request context. Patch CreateExplorePermalinkCommand.run
to fail explicitly so the test deterministically exercises the form_data_key
fallback, matching the test's stated intent.
2026-06-06 18:58:39 +00:00
Amin Ghadersohi
c8d6f09aa9 fix(mcp): narrow explore-link exception handling to expected failure modes
Address review feedback that the permalink-creation except was too broad.
CreateExplorePermalinkCommand already wraps its internal failures into
ExplorePermalinkCreateFailedError, so catch only that and SQLAlchemyError
in the permalink and outer fallback blocks. Programming errors (TypeError,
AttributeError, KeyError, ValueError) now surface to the tool handler
instead of being silently masked behind a fallback URL.
2026-06-05 22:58:57 +00:00
Amin Ghadersohi
0f65ec35ca Merge branch 'master' into mcp-chart-explore-url 2026-06-05 15:55:41 -07:00
Amin Ghadersohi
d694de8856 fix(mcp): relocate extract_permalink_key_from_url and fix UUID datasource
- Move extract_permalink_key_from_url from chart_helpers to url_utils
  and reimplement via urlparse path parsing instead of regex
- Fix generate_explore_link tool to use dataset.id (resolved numeric ID)
  instead of request.dataset_id (may be UUID string) when building the
  datasource field in the returned form_data dict
- Add unit tests for extract_permalink_key_from_url in test_url_utils.py
2026-06-04 18:30:42 +00:00
Amin Ghadersohi
62fae8c493 fix(mcp): generate durable explore permalink URL instead of ephemeral form_data_key
The `generate_explore_link` MCP tool previously returned a URL like
`/explore/?form_data_key=<key>` backed by Redis cache (~24h expiry).
When the key expired the URL became unreachable, preventing LLMs from
iteratively building and sharing charts.

This commit changes the URL strategy:
1. Try `CreateExplorePermalinkCommand` first (DB-backed key-value store,
   does not expire) → `/explore/p/<key>/`
2. Fall back to `MCPCreateFormDataCommand` (Redis cache) on permalink
   failure → `/explore/?form_data_key=<key>`
3. Fall back to plain dataset URL on both failing.

The tool response now includes a `permalink_key` field (populated when
the durable permalink path is used) alongside the existing `form_data_key`
(populated only in the Redis-fallback case).

`extract_permalink_key_from_url()` added to `chart_helpers.py`.
Tests updated to mock `CreateExplorePermalinkCommand.run` by default and
cover both the permalink path and each fallback tier.
2026-06-04 18:13:20 +00:00
58 changed files with 2062 additions and 2895 deletions

View File

@@ -109,7 +109,7 @@ dependencies = [
"watchdog>=6.0.0",
"wtforms>=2.3.3, <4",
"wtforms-json",
"xlsxwriter>=3.2.9, <3.3",
"xlsxwriter>=3.0.7, <3.3",
]
[project.optional-dependencies]
@@ -165,7 +165,7 @@ hive = [
"thrift_sasl>=0.4.3, < 1.0.0",
]
impala = ["impyla>0.16.2, <0.23"]
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
kylin = ["kylinpy>=2.8.1, <2.9"]
mssql = ["pymssql>=2.2.8, <3"]
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
@@ -180,7 +180,7 @@ ocient = [
oracle = ["cx-Oracle>8.0.0, <8.4"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]
playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
@@ -199,15 +199,15 @@ spark = [
]
tdengine = [
"taospy>=2.7.21",
"taos-ws-py>=0.6.9"
"taos-ws-py>=0.3.8"
]
teradata = ["teradatasql>=16.20.0.23"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
vertica = ["sqlalchemy-vertica-python>= 0.5.9, < 0.7"]
netezza = ["nzalchemy>=11.0.2"]
starrocks = ["starrocks>=1.0.0"]
doris = ["pydoris>=1.0.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
oceanbase = ["oceanbase_py>=0.0.1"]
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
@@ -231,7 +231,7 @@ development = [
"pytest-asyncio",
"pytest-cov",
"pytest-mock",
"python-ldap>=3.4.7",
"python-ldap>=3.4.4",
"ruff",
"sqloxide",
"statsd",

View File

@@ -490,7 +490,7 @@ wtforms-json==0.3.5
# via apache-superset (pyproject.toml)
xlrd==2.0.1
# via pandas
xlsxwriter==3.2.9
xlsxwriter==3.0.9
# via
# apache-superset (pyproject.toml)
# pandas

View File

@@ -838,7 +838,7 @@ python-dotenv==1.2.2
# apache-superset
# fastmcp
# pydantic-settings
python-ldap==3.4.7
python-ldap==3.4.5
# via apache-superset
python-multipart==0.0.29
# via mcp
@@ -1140,7 +1140,7 @@ xlrd==2.0.1
# via
# -c requirements/base-constraint.txt
# pandas
xlsxwriter==3.2.9
xlsxwriter==3.0.9
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -63,57 +63,56 @@ describe('Add database', () => {
it('show error alerts on dynamic form for bad host', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('badhost', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5432', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]')
.type('testusername', { force: true })
.blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.get('input[name="host"]').type('badhost', { force: true });
cy.get('input[name="port"]').type('5432', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
"The hostname provided can't be resolved",
).should('exist');
});
});
});
it('show error alerts on dynamic form for bad port', () => {
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]')
.type('testusername', { force: true })
.blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.get('input[name="host"]').type('localhost', { force: true });
cy.get('body').click(0, 0);
cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
cy.get('input[name="port"]').type('5430', { force: true });
cy.get('input[name="database"]').type('testdb', { force: true });
cy.get('input[name="username"]').type('testusername', { force: true });
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains('.ant-form-item-explain-error', 'The port is closed').should(
'exist',
);
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0);
cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.contains(
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
@@ -315,7 +315,7 @@
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.32",
"cheerio": "1.2.0",
"concurrently": "^10.0.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",

View File

@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success'
}
help={errorMessage || helpText}
hasFeedback={isValidating || !!hasError}
hasFeedback={!!hasError}
>
{visibilityToggle || props.name === 'password' ? (
<StyledInputPassword

View File

@@ -31,6 +31,5 @@ export interface LabeledErrorBoundInputProps {
id?: string;
classname?: string;
visibilityToggle?: boolean;
isValidating?: boolean;
[x: string]: any;
}

View File

@@ -519,8 +519,7 @@ const Select = forwardRef(
handleSelectAll();
}}
>
{t('Select all')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
</Button>
<Button
type="link"
@@ -537,8 +536,7 @@ const Select = forwardRef(
handleDeselectAll();
}}
>
{t('Clear')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
</Button>
</StyledBulkActionsContainer>
),

View File

@@ -182,7 +182,10 @@ testWithAssets(
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
postsAfterClearAll.push(req.url());
}
};

View File

@@ -164,8 +164,7 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
processedData = filteredData.map(d => ({
...d,
radius: radiusScale(Math.sqrt(d.m2)),
fillColor:
d.m1 != null ? (colorFn(d.m1) ?? theme.colorBorder) : theme.colorBorder,
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
}));
}

View File

@@ -37,7 +37,6 @@ import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndContext } from '@dnd-kit/core';
import reducerIndex from 'spec/helpers/reducerIndex';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -48,7 +47,6 @@ import userEvent from '@testing-library/user-event';
type Options = Omit<RenderOptions, 'queries'> & {
useRedux?: boolean;
useDnd?: boolean;
useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
useQueryParams?: boolean;
useRouter?: boolean;
useTheme?: boolean;
@@ -76,7 +74,6 @@ export const defaultStore = createStore();
export function createWrapper(options?: Options) {
const {
useDnd,
useDndKit,
useRedux,
useQueryParams,
useRouter,
@@ -99,10 +96,6 @@ export function createWrapper(options?: Options) {
);
}
if (useDndKit) {
result = <DndContext>{result}</DndContext>;
}
if (useDnd) {
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;

View File

@@ -47,13 +47,3 @@ test('should pass removeToast to the Toast component', async () => {
fireEvent.click(getAllByTestId('close-button')[0]);
await waitFor(() => expect(removeToast).toHaveBeenCalledTimes(1));
});
test('presenter caps its height with max-height so it hugs the toasts', () => {
// A fixed `height` would make the fixed overlay span the viewport and block
// controls underneath it; `max-height` lets it shrink to the toasts while
// still scrolling when they overflow.
const presenter = setup().container.querySelector('#toast-presenter');
expect(presenter).toBeInTheDocument();
expect(presenter).toHaveStyleRule('max-height', 'calc(100vh - 100px)');
expect(presenter).not.toHaveStyleRule('height', 'calc(100vh - 100px)');
});

View File

@@ -37,9 +37,7 @@ const StyledToastPresenter = styled.div<VisualProps>(
z-index: ${theme.zIndexPopupBase + 1};
word-break: break-word;
/* Cap height for scrolling, but hug the toasts so the fixed overlay does not
reserve the full viewport and block controls underneath it. */
max-height: calc(100vh - 100px);
height: calc(100vh - 100px);
display: flex;
flex-direction: ${position === 'bottom' ? 'column-reverse' : 'column'};

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import DashboardWrapper from './DashboardWrapper';
@@ -38,6 +39,50 @@ test('should render children', () => {
expect(getByTestId('mock-children')).toBeInTheDocument();
});
// Note: Drag-and-drop test removed - DashboardWrapper uses react-dnd but
// OptionControlLabel uses @dnd-kit, causing cross-library compatibility issues.
// This test requires proper @dnd-kit testing utilities.
test('should update the style on dragging state', async () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
const { container, getByText } = render(
<DashboardWrapper>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Label 1</span>}
/>
<OptionControlLabel
{...defaultProps}
index={2}
label={<span>Label 2</span>}
/>
</DashboardWrapper>,
{
useRedux: true,
useDnd: true,
initialState: {
dashboardState: {
editMode: true,
},
},
},
);
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
fireEvent.dragStart(getByText('Label 1'));
jest.runAllTimers();
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(1);
fireEvent.dragEnd(getByText('Label 1'));
// immediately discards dragging state after dragEnd
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
});

View File

@@ -285,9 +285,7 @@ test('clears undo history after hydrating the dashboard', async () => {
expect(hydrateDashboard).toHaveBeenCalled();
expect(clearDashboardHistory).toHaveBeenCalled();
const hydrateOrder = (hydrateDashboard as jest.Mock).mock
.invocationCallOrder[0];
const clearOrder = (clearDashboardHistory as jest.Mock).mock
.invocationCallOrder[0];
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
expect(clearOrder).toBeGreaterThan(hydrateOrder);
});

View File

@@ -26,7 +26,7 @@ test('should render', async () => {
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDndKit: true, useRedux: true, initialState: { explore: {} } },
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
);
expect(
@@ -34,3 +34,17 @@ test('should render', async () => {
).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();
});
test('should have attribute draggable:true', async () => {
render(
<DatasourcePanelDragOption
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
);
expect(
await screen.findByTestId('DatasourcePanelDragOption'),
).toHaveAttribute('draggable', 'true');
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { RefObject, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { useDrag } from 'react-dnd';
import { useSelector } from 'react-redux';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
@@ -32,8 +32,8 @@ import { ExplorePageState } from 'src/explore/types';
import { DatasourcePanelDndItem } from '../types';
const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
${({ theme, isDragging }) => css`
const DatasourceItemContainer = styled.div`
${({ theme }) => css`
display: flex;
align-items: center;
justify-content: space-between;
@@ -46,8 +46,6 @@ const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
border-radius: 4px;
cursor: ${isDragging ? 'grabbing' : 'grab'};
opacity: ${isDragging ? 0.5 : 1};
&:hover {
background-color: ${theme.colorPrimaryBgHover};
@@ -100,26 +98,15 @@ export default function DatasourcePanelDragOption(
return true;
}, [type, value, compatibleMetrics, compatibleDimensions]);
// Create a unique ID for this draggable item
const draggableId = useMemo(() => {
if (type === DndItemType.Column) {
const col = value as ColumnMeta;
return `datasource-${type}-${col.column_name || col.verbose_name}`;
}
const metric = value as MetricOption;
return `datasource-${type}-${metric.metric_name || metric.label}`;
}, [type, value]);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggableId,
data: {
type,
value,
const [{ isDragging }, drag] = useDrag({
item: {
value: props.value,
type: props.type,
},
// @dnd-kit equivalent of react-dnd's `canDrag: isCompatible`. Disabling
// the draggable suppresses pointer activation entirely so incompatible
// items can't be picked up at all (matched in the visual style below).
disabled: !isCompatible,
canDrag: isCompatible,
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
const optionProps = {
@@ -131,13 +118,10 @@ export default function DatasourcePanelDragOption(
return (
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={setNodeRef}
isDragging={isDragging}
{...attributes}
{...listeners}
ref={drag}
style={{
opacity: isCompatible ? undefined : 0.35,
cursor: isCompatible ? undefined : 'not-allowed',
opacity: isCompatible ? 1 : 0.35,
cursor: isCompatible ? 'grab' : 'not-allowed',
}}
>
{type === DndItemType.Column ? (

View File

@@ -17,37 +17,13 @@
* under the License.
*/
import { useContext } from 'react';
import type { DragStartEvent } from '@dnd-kit/core';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
// @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
// cannot dispatch. To exercise the drag-start gating we capture the
// `onDragStart` handler the provider registers on DndContext and invoke it
// directly with a synthetic event.
let capturedOnDragStart: ((event: DragStartEvent) => void) | undefined;
jest.mock('@dnd-kit/core', () => {
const actual = jest.requireActual('@dnd-kit/core');
return {
...actual,
DndContext: ({
children,
onDragStart,
}: {
children: React.ReactNode;
onDragStart?: (event: DragStartEvent) => void;
}) => {
capturedOnDragStart = onDragStart;
return children;
},
};
});
beforeEach(() => {
capturedOnDragStart = undefined;
});
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
const MockChildren = () => {
const dragging = useContext(DraggingContext);
@@ -81,62 +57,57 @@ test('should render children', () => {
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
{ useRedux: true, useDnd: true },
);
expect(getByTestId('mock-children')).toBeInTheDocument();
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should initially have dragging set to false', () => {
test('should only propagate dragging state when dragging the panel option', () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
const { container, getByText } = render(
<ExploreContainer>
<DatasourcePanelDragOption
value={{ metric_name: 'panel option', uuid: '1' }}
type={DndItemType.Metric}
/>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Metric item</span>}
/>
<OptionWrapper
{...defaultProps}
index={2}
label="Column item"
clickClose={() => {}}
onShiftOptions={() => {}}
/>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
{
useRedux: true,
useDnd: true,
},
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
expect(getByText('not dragging')).toBeInTheDocument();
});
test('propagates dragging state when dragging a panel option', () => {
const { container } = render(
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// Dragging a DatasourcePanel option (no `dragIndex`) sets the dragging state.
act(() => {
capturedOnDragStart?.({
active: { id: 'panel', data: { current: { type: 'metric' } } },
} as unknown as DragStartEvent);
});
fireEvent.dragStart(getByText('panel option'));
expect(container.getElementsByClassName('dragging')).toHaveLength(1);
});
test('does not propagate dragging state for an in-list reorder', () => {
const { container } = render(
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true },
);
fireEvent.dragEnd(getByText('panel option'));
fireEvent.dragStart(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// An in-list sortable reorder carries a `dragIndex` and must NOT set the
// dragging state (it would otherwise surface drop targets during a reorder).
act(() => {
capturedOnDragStart?.({
active: {
id: 'sortable',
data: { current: { type: 'metric', dragIndex: 0 } },
},
} as unknown as DragStartEvent);
});
fireEvent.dragEnd(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// don't show dragging state for the sorting item
fireEvent.dragStart(getByText('Column item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
});
@@ -145,7 +116,10 @@ test('should manage the dropValidators', () => {
<ExploreContainer>
<MockChildren2 />
</ExploreContainer>,
{ useRedux: true },
{
useRedux: true,
useDnd: true,
},
);
expect(queryByText('test_item_1')).not.toBeInTheDocument();

View File

@@ -1,125 +0,0 @@
/**
* 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 {
ActiveDragData,
DroppableData,
resolveDragEnd,
} from './ExploreDndContext';
const COLUMN = 'column';
const METRIC = 'metric';
const active = (data: ActiveDragData, id = 'drag-source') => ({
id,
data: { current: data },
});
const over = (
data: Partial<ActiveDragData> & DroppableData,
id = 'dropzone-target',
) => ({
id,
data: { current: data },
});
test('reorder fires the active item onShiftOptions callback', () => {
const onShiftOptions = jest.fn();
resolveDragEnd(
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
over({ type: COLUMN, dragIndex: 2 }, 'sortable-column-2'),
);
expect(onShiftOptions).toHaveBeenCalledWith(0, 2);
});
test('reorder falls back to onMoveLabel when onShiftOptions is absent', () => {
const onMoveLabel = jest.fn();
const onDropLabel = jest.fn();
resolveDragEnd(
active(
{ type: METRIC, dragIndex: 1, onMoveLabel, onDropLabel },
'sortable-metric-1',
),
over({ type: METRIC, dragIndex: 0 }, 'sortable-metric-0'),
);
expect(onMoveLabel).toHaveBeenCalledWith(1, 0);
expect(onDropLabel).toHaveBeenCalled();
});
test('reorder does not fire across mismatched types', () => {
const onShiftOptions = jest.fn();
resolveDragEnd(
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
over({ type: METRIC, dragIndex: 1 }, 'sortable-metric-1'),
);
expect(onShiftOptions).not.toHaveBeenCalled();
});
test('external drop fires onDrop and onDropValue when accepted', () => {
const onDrop = jest.fn();
const onDropValue = jest.fn();
const value = { column_name: 'a' };
resolveDragEnd(
active({ type: COLUMN, value }),
over({ accept: [COLUMN], canDrop: () => true, onDrop, onDropValue }),
);
expect(onDrop).toHaveBeenCalledWith({ type: COLUMN, value });
expect(onDropValue).toHaveBeenCalledWith(value);
});
test('external drop is blocked when the type is not accepted', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: METRIC, value: { metric_name: 'm' } }),
over({ accept: [COLUMN], canDrop: () => true, onDrop }),
);
expect(onDrop).not.toHaveBeenCalled();
});
test('external drop is blocked when canDrop rejects the item', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: { column_name: 'dupe' } }),
over({ accept: [COLUMN], canDrop: () => false, onDrop }),
);
expect(onDrop).not.toHaveBeenCalled();
});
test('drop with no canDrop validator defaults to accepting the item', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: { column_name: 'a' } }),
over({ accept: [COLUMN], onDrop }),
);
expect(onDrop).toHaveBeenCalled();
});
test('no-op when there is no droppable target', () => {
expect(() =>
resolveDragEnd(active({ type: COLUMN, value: {} }), null),
).not.toThrow();
});
test('no-op when dropping onto itself', () => {
const onDrop = jest.fn();
resolveDragEnd(
active({ type: COLUMN, value: {} }, 'same'),
over({ accept: [COLUMN], onDrop }, 'same'),
);
expect(onDrop).not.toHaveBeenCalled();
});

View File

@@ -1,244 +0,0 @@
/**
* 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 {
createContext,
useContext,
useState,
useCallback,
FC,
Dispatch,
useReducer,
} from 'react';
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
DragStartEvent,
DragEndEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
/**
* Type for the active drag item data
*/
export interface ActiveDragData {
type: string;
value?: unknown;
dragIndex?: number;
// For sortable items - callback to handle reorder
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
}
/**
* Context to track if something is being dragged (for visual feedback)
*/
export const DraggingContext = createContext(false);
/**
* Context to access the currently active drag item
*/
export const ActiveDragContext = createContext<ActiveDragData | null>(null);
/**
* Dropzone validation - used by controls to register what they can accept
*/
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
/**
* Shape of the data a droppable (e.g. DndSelectLabel) exposes via its
* `useDroppable` data object so that drops can be dispatched on drag end.
*/
export interface DroppableData {
accept?: string[];
canDrop?: (item: DatasourcePanelDndItem) => boolean;
onDrop?: (item: DatasourcePanelDndItem) => void;
onDropValue?: (value: DatasourcePanelDndItem['value']) => void;
}
/**
* Pure dispatch logic for a @dnd-kit drag-end event, extracted so it can be
* unit-tested without simulating pointer events (which jsdom cannot drive).
*
* Mirrors the original react-dnd behavior:
* - Same-list sortable reorder fires the active item's reorder callback.
* - External drops (DatasourcePanel -> control) only fire `onDrop` when the
* dragged item's type is accepted AND the droppable's `canDrop` validator
* passes (react-dnd never fired `drop` when `canDrop` was false).
*/
export function resolveDragEnd(
active: { id: UniqueIdentifier; data: { current?: ActiveDragData } },
over: {
id: UniqueIdentifier;
data: { current?: Partial<ActiveDragData> & DroppableData };
} | null,
): void {
if (!over || active.id === over.id) {
return;
}
const activeData = active.data.current;
const overData = over.data.current;
// Same-list sortable reorder: both endpoints carry a dragIndex and type.
if (
activeData &&
overData &&
typeof activeData.dragIndex === 'number' &&
typeof overData.dragIndex === 'number' &&
activeData.type === overData.type
) {
const reorderCallback = activeData.onShiftOptions || activeData.onMoveLabel;
reorderCallback?.(activeData.dragIndex, overData.dragIndex);
activeData.onDropLabel?.();
return;
}
// External drop onto a droppable that exposes an onDrop handler.
if (activeData && overData?.onDrop) {
const { accept, canDrop, onDrop, onDropValue } = overData;
const item: DatasourcePanelDndItem = {
type: activeData.type as DatasourcePanelDndItem['type'],
value: activeData.value as DatasourcePanelDndItem['value'],
};
const typeAccepted = !accept || accept.includes(item.type);
if (typeAccepted && (canDrop?.(item) ?? true)) {
onDrop(item);
onDropValue?.(item.value);
}
}
}
interface ExploreDndContextProps {
children: React.ReactNode;
}
/**
* DnD context provider for the Explore view.
* Wraps @dnd-kit/core's DndContext and provides:
* - Dragging state tracking (for visual feedback)
* - Dropzone registration (for validation)
* - Drop dispatch via each droppable's `useDroppable` data object
*/
export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
children,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
const dropzoneValue = useReducer(dropzoneReducer, {});
// Configure sensors for drag detection. PointerSensor drives mouse/touch
// drags; KeyboardSensor adds keyboard-accessible reordering (an a11y win
// over the previous react-dnd HTML5 backend, which had none).
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px movement required before drag starts
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const data = active.data.current as ActiveDragData | undefined;
// Don't set dragging state for reordering within a list
if (data && 'dragIndex' in data) {
return;
}
setIsDragging(true);
setActiveData(data || null);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setIsDragging(false);
setActiveData(null);
resolveDragEnd(
active as Parameters<typeof resolveDragEnd>[0],
over as Parameters<typeof resolveDragEnd>[1],
);
}, []);
const handleDragCancel = useCallback(() => {
setIsDragging(false);
setActiveData(null);
}, []);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={isDragging}>
<ActiveDragContext.Provider value={activeData}>
{children}
</ActiveDragContext.Provider>
</DraggingContext.Provider>
</DropzoneContext.Provider>
</DndContext>
);
};
/**
* Hook to check if something is currently being dragged
*/
export const useIsDragging = () => useContext(DraggingContext);
/**
* Hook to get the active drag data
*/
export const useActiveDrag = () => useContext(ActiveDragContext);

View File

@@ -16,17 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, ReactNode } from 'react';
import { styled } from '@apache-superset/core/theme';
import {
ExploreDndContextProvider,
DraggingContext,
DropzoneContext,
} from './ExploreDndContext';
createContext,
useEffect,
useState,
Dispatch,
FC,
ReactNode,
useReducer,
} from 'react';
// Re-export contexts for backward compatibility
export { DraggingContext, DropzoneContext };
import { styled } from '@apache-superset/core/theme';
import { useDragDropManager } from 'react-dnd';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
export const DraggingContext = createContext(false);
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const StyledDiv = styled.div`
display: flex;
flex-direction: column;
@@ -34,10 +46,53 @@ const StyledDiv = styled.div`
min-height: 0;
`;
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => (
<ExploreDndContextProvider>
<StyledDiv>{children}</StyledDiv>
</ExploreDndContextProvider>
);
const reducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => {
const dragDropManager = useDragDropManager();
const [dragging, setDragging] = useState(
dragDropManager.getMonitor().isDragging(),
);
useEffect(() => {
const monitor = dragDropManager.getMonitor();
const unsub = monitor.subscribeToStateChange(() => {
const item = monitor.getItem() || {};
// don't show dragging state for the sorting item
if ('dragIndex' in item) {
return;
}
const isDragging = monitor.isDragging();
setDragging(isDragging);
});
return () => {
unsub();
};
}, [dragDropManager]);
const dropzoneValue = useReducer(reducer, {});
return (
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={dragging}>
<StyledDiv>{children}</StyledDiv>
</DraggingContext.Provider>
</DropzoneContext.Provider>
);
};
export default ExploreContainer;

View File

@@ -127,8 +127,6 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
sortableType="ContourOption"
itemCount={contours.length}
{...props}
/>
<ContourPopoverTrigger

View File

@@ -16,41 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import { DndItemType } from 'src/explore/components/DndItemType';
import {
CapturedDroppable,
CapturedSortables,
captureDroppableData,
captureSortableData,
simulateDrop,
simulateReorder,
} from './dndTestUtils';
const captured: CapturedDroppable = { current: undefined };
const sortables: CapturedSortables = { items: [] };
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
jest.mock('@dnd-kit/sortable', () => ({
...jest.requireActual('@dnd-kit/sortable'),
useSortable: jest.fn(),
}));
beforeEach(() => {
captured.current = undefined;
sortables.items = [];
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
});
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
name: 'test-control',
@@ -93,7 +67,7 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndColumnMetricSelect {...defaultProps} />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(
@@ -103,7 +77,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndColumnMetricSelect {...defaultProps} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(
@@ -114,122 +88,148 @@ test('renders with default props and multi = true', () => {
test('render selected columns and metrics correctly', () => {
const values = ['column_a', 'metric_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
test('can drop columns and metrics', () => {
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_b', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_b' } as any,
});
expect(onChange).toHaveBeenLastCalledWith([
'column_a',
'metric_a',
'column_b',
]);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_b' } as any,
});
expect(onChange).toHaveBeenLastCalledWith([
'column_a',
'metric_a',
'metric_b',
]);
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('cannot drop duplicate items', () => {
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_a', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_a' } as any,
});
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
expect(onChange).not.toHaveBeenCalled();
const initialCount = currentSelection.children.length;
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
});
test('can drop only selected metrics', () => {
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
const values = ['column_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
// metric_c is not in selectedMetrics -> rejected
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_c' } as any,
});
expect(onChange).not.toHaveBeenCalled();
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const unselectedMetric = screen.getAllByTestId(
'DatasourcePanelDragOption',
)[1];
const currentSelection = getByTestId('dnd-labels-container');
// metric_a is in selectedMetrics -> accepted
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
expect(onChange).toHaveBeenLastCalledWith(['column_a', 'metric_a']);
const initialCount = currentSelection.children.length;
fireEvent.dragStart(unselectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
fireEvent.dragStart(selectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('can drag and reorder items', () => {
const onChange = jest.fn();
render(
<DndColumnMetricSelect
{...defaultProps}
value={['column_a', 'metric_a', 'column_b']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
test('can drag and reorder items', async () => {
const values = ['column_a', 'metric_a', 'column_b'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
// Reorder is dispatched via the active sortable item's onShiftOptions,
// which the control registers on each OptionWrapper. Drag index 0
// (column_a) onto index 2 (column_b) and verify the swap.
simulateReorder(sortables, 0, 2);
expect(onChange).toHaveBeenLastCalledWith([
'column_b',
'metric_a',
'column_a',
]);
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstItem = container.children[0] as HTMLElement;
const lastItem = container.children[2] as HTMLElement;
expect(within(firstItem).getByText('column_a')).toBeVisible();
expect(within(lastItem).getByText('Column B')).toBeVisible();
fireEvent.dragStart(firstItem);
fireEvent.dragEnter(lastItem);
fireEvent.dragOver(lastItem);
fireEvent.drop(lastItem);
expect(container).toBeInTheDocument();
});
test('shows warning for aggregated DeckGL charts', () => {
@@ -243,7 +243,7 @@ test('shows warning for aggregated DeckGL charts', () => {
multi
formData={formData}
/>,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
const columnItem = screen.getByText('column_a');
@@ -261,7 +261,7 @@ test('handles single selection mode', () => {
multi={false}
onChange={onChange}
/>,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
@@ -275,7 +275,7 @@ test('handles custom ghost button text', () => {
render(
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
expect(screen.getByText(customText)).toBeInTheDocument();
@@ -292,11 +292,10 @@ test('can remove items by clicking close button', () => {
multi
onChange={onChange}
/>,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
// Use testId instead of role selector - @dnd-kit sortable wrapper adds extra button elements
const closeButtons = screen.getAllByTestId('remove-control-button');
const closeButtons = screen.getAllByRole('button', { name: /close/i });
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[0]);
@@ -313,7 +312,7 @@ test('handles adhoc metric with error', () => {
const values = [errorMetric];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
@@ -325,7 +324,7 @@ test('handles adhoc column values', () => {
const values = ['column_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
@@ -337,7 +336,7 @@ test('handles mixed value types correctly', () => {
render(
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();

View File

@@ -61,7 +61,7 @@ const defaultProps: DndColumnSelectProps = {
test('renders with default props', async () => {
render(<DndColumnSelect {...defaultProps} />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(
@@ -71,7 +71,7 @@ test('renders with default props', async () => {
test('renders with value', async () => {
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(await screen.findByText('Column A')).toBeInTheDocument();
@@ -87,7 +87,7 @@ test('renders adhoc column', async () => {
expressionType: 'SQL',
}}
/>,
{ useDndKit: true, useRedux: true },
{ useDnd: true, useRedux: true },
);
expect(await screen.findByText('adhoc column')).toBeVisible();
expect(screen.getByLabelText('calculator')).toBeVisible();
@@ -110,7 +110,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
value={columnValues}
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);
@@ -167,7 +167,7 @@ test('should allow selecting columns via click interface', async () => {
});
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});
@@ -200,7 +200,7 @@ test('should display selected column values correctly', async () => {
});
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});
@@ -233,7 +233,7 @@ test('should handle multiple column selections for groupby', async () => {
});
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});
@@ -269,7 +269,7 @@ test('should support adhoc column creation workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});
@@ -299,7 +299,7 @@ test('should verify onChange callback integration (core regression protection)',
};
const { rerender } = render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
@@ -334,7 +334,7 @@ test('should render column selection interface elements', async () => {
};
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
@@ -374,7 +374,7 @@ test('should complete full column selection workflow like original Cypress test'
});
const { rerender } = render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});
@@ -450,7 +450,7 @@ test('should create adhoc column via Custom SQL tab workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDndKit: true,
useDnd: true,
store,
});

View File

@@ -204,9 +204,6 @@ function DndColumnSelect(props: DndColumnSelectProps) {
[ghostButtonText, multi],
);
// Generate sortable type that matches OptionWrapper's type
const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
return (
<div>
<DndSelectLabel
@@ -217,8 +214,6 @@ function DndColumnSelect(props: DndColumnSelectProps) {
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={openPopover}
sortableType={sortableType}
itemCount={optionSelector.values.length}
{...props}
/>
<ColumnSelectPopoverTrigger

View File

@@ -27,14 +27,11 @@ import {
import { GenericDataType } from '@apache-superset/core/common';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { useDroppable } from '@dnd-kit/core';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Operators } from 'src/explore/constants';
@@ -44,13 +41,9 @@ import {
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { ExpressionTypes } from '../FilterControl/types';
import { DndItemType } from '../../DndItemType';
import { Datasource } from '../../../types';
import {
CapturedDroppable,
captureDroppableData,
simulateDrop,
} from './dndTestUtils';
import { DndItemType } from '../../DndItemType';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
jest.mock('src/core/editors', () => ({
EditorHost: ({ value }: { value: string }) => (
@@ -58,21 +51,6 @@ jest.mock('src/core/editors', () => ({
),
}));
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
const captured: CapturedDroppable = { current: undefined };
beforeEach(() => {
jest.clearAllMocks();
captured.current = undefined;
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
});
const defaultProps: Omit<DndFilterSelectProps, 'datasource'> = {
type: 'DndFilterSelect',
name: 'Filter',
@@ -118,8 +96,12 @@ function setup({
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders with default props', async () => {
render(setup(), { useDndKit: true, store });
render(setup(), { useDnd: true, store });
expect(
await screen.findByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -131,7 +113,7 @@ test('renders with value', async () => {
expressionType: ExpressionTypes.Sql,
});
render(setup({ value }), {
useDndKit: true,
useDnd: true,
store,
});
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
@@ -146,7 +128,7 @@ test('renders options with saved metric', async () => {
},
}),
{
useDndKit: true,
useDnd: true,
store,
},
);
@@ -168,7 +150,7 @@ test('renders options with column', async () => {
],
}),
{
useDndKit: true,
useDnd: true,
store,
},
);
@@ -190,7 +172,7 @@ test('renders options with adhoc metric', async () => {
},
}),
{
useDndKit: true,
useDnd: true,
store,
},
);
@@ -199,43 +181,60 @@ test('renders options with adhoc metric', async () => {
).toBeInTheDocument();
});
test('cannot drop a column that is not part of the simple column selection', async () => {
test('cannot drop a column that is not part of the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
}),
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'order_date' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ column_name: 'address_line1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
}}
type={DndItemType.Metric}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
</>,
{
useDndKit: true,
useDnd: true,
store,
},
);
// A column missing from the simple column selection is rejected by canDrop,
// so no filter popover opens.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'address_line1' } as any,
});
});
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableColumn = selections[1];
const metricType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
// An acceptable column opens the popover prefilled with that column.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'order_date' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('order_date')).toBeInTheDocument();
fireEvent.keyDown(filterConfigPopup, {
@@ -244,111 +243,15 @@ test('cannot drop a column that is not part of the simple column selection', asy
keyCode: 27,
charCode: 27,
});
await waitFor(() =>
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument(),
);
// A metric type is accepted (adhoc metrics are allowed here).
act(() => {
simulateDrop(captured, {
type: DndItemType.Metric,
value: {
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
} as any,
});
});
const metricPopup = await screen.findByTestId('filter-edit-popover');
expect(within(metricPopup).getByTestId('react-ace')).toHaveTextContent(
'AGG(metric_a)',
);
});
test('when disallow_adhoc_metrics is set, can drop a column from the simple column selection', async () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
}),
{
useDndKit: true,
store,
},
);
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_b' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('when disallow_adhoc_metrics is set, cannot drop anything but a simple column selection', async () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
render(
setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
}),
{
useDndKit: true,
store,
},
);
// A metric is rejected when adhoc metrics are disallowed.
act(() => {
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a', uuid: '1' } as any,
});
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
// An adhoc metric option is likewise rejected.
act(() => {
simulateDrop(captured, {
type: DndItemType.AdhocMetricOption,
value: { metric_name: 'avg__num', uuid: '2' } as any,
});
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(metricType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
// A column from the simple selection is accepted.
act(() => {
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_c' } as any,
});
});
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
expect(
within(screen.getByTestId('filter-edit-popover')).getByTestId('react-ace'),
).toHaveTextContent('AGG(metric_a)');
});
test('calls onChange when close is clicked and canDelete is true', () => {
@@ -365,7 +268,7 @@ test('calls onChange when close is clicked and canDelete is true', () => {
const canDelete = jest.fn();
canDelete.mockReturnValue(true);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDndKit: true,
useDnd: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -387,7 +290,7 @@ test('onChange is not called when close is clicked and canDelete is false', () =
const canDelete = jest.fn();
canDelete.mockReturnValue(false);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDndKit: true,
useDnd: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -409,7 +312,7 @@ test('onChange is not called when close is clicked and canDelete is string, warn
const canDelete = jest.fn();
canDelete.mockReturnValue('Test warning');
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDndKit: true,
useDnd: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -417,3 +320,109 @@ test('onChange is not called when close is clicked and canDelete is string, warn
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(await screen.findByText('Test warning')).toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when disallow_adhoc_metrics is set', () => {
test('can drop a column type from the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b' }}
type={DndItemType.Column}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
})}
</>,
{
useDnd: true,
store,
},
);
const acceptableColumn = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('cannot drop any other types of selections apart from simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_c' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'avg__num', uuid: '2' }}
type={DndItemType.AdhocMetricOption}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
});
});

View File

@@ -454,8 +454,6 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
accept={DND_ACCEPTED_TYPES}
ghostButtonText={t('Drop columns/metrics here or click')}
onClickGhostButton={handleClickGhostButton}
sortableType={DndItemType.FilterOption}
itemCount={values.length}
{...props}
/>
<AdhocFilterPopoverTrigger

View File

@@ -20,46 +20,15 @@ import {
fireEvent,
render,
screen,
within,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { useDroppable } from '@dnd-kit/core';
import { useSortable } from '@dnd-kit/sortable';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
import {
CapturedDroppable,
CapturedSortables,
captureDroppableData,
captureSortableData,
simulateDrop,
simulateReorder,
} from './dndTestUtils';
const captured: CapturedDroppable = { current: undefined };
const sortables: CapturedSortables = { items: [] };
jest.mock('@dnd-kit/core', () => ({
...jest.requireActual('@dnd-kit/core'),
useDroppable: jest.fn(),
}));
jest.mock('@dnd-kit/sortable', () => ({
...jest.requireActual('@dnd-kit/sortable'),
useSortable: jest.fn(),
}));
beforeEach(() => {
captured.current = undefined;
sortables.items = [];
(useDroppable as jest.Mock).mockImplementation(
captureDroppableData(captured),
);
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
});
const defaultProps = {
savedMetrics: [
@@ -101,7 +70,7 @@ const adhocMetricB = {
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(
@@ -111,7 +80,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(
@@ -122,7 +91,7 @@ test('renders with default props and multi = true', () => {
test('render selected metrics correctly', () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDndKit: true,
useDnd: true,
useRedux: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
@@ -144,7 +113,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
multi
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);
@@ -197,7 +166,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing
multi={false}
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);
@@ -256,7 +225,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async
multi
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);
@@ -299,7 +268,7 @@ test('update adhoc metric name when column label in dataset changes', () => {
multi
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);
@@ -342,107 +311,156 @@ test('update adhoc metric name when column label in dataset changes', () => {
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
});
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
test('can drag metrics', async () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
useRedux: true,
});
test('can drag metrics (reorder dispatches through the reorder + drop path)', () => {
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_a', 'metric_b', adhocMetricB]}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
// DndMetricSelect reorders via moveLabel (internal state) finalized by
// onDropLabel. Verify both callbacks were registered on the sortable items
// and the drag-end path invokes them (which commits the change via onChange).
expect(sortables.items.length).toBeGreaterThanOrEqual(3);
expect(typeof sortables.items[0].onMoveLabel).toBe('function');
expect(typeof sortables.items[0].onDropLabel).toBe('function');
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
simulateReorder(sortables, 0, 2);
expect(onChange).toHaveBeenCalled();
const firstMetric = container.children[0] as HTMLElement;
const lastMetric = container.children[2] as HTMLElement;
expect(within(firstMetric).getByText('metric_a')).toBeVisible();
expect(within(lastMetric).getByText('SUM(Column B)')).toBeVisible();
fireEvent.mouseOver(within(firstMetric).getByText('metric_a'));
expect(await screen.findByText('Metric name')).toBeInTheDocument();
fireEvent.dragStart(firstMetric);
fireEvent.dragEnter(lastMetric);
fireEvent.dragOver(lastMetric);
fireEvent.drop(lastMetric);
expect(within(firstMetric).getByText('SUM(Column B)')).toBeVisible();
expect(within(lastMetric).getByText('metric_a')).toBeVisible();
});
test('cannot drop a duplicated item', () => {
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_a']}
onChange={onChange}
multi
/>,
{ useDndKit: true, useRedux: true },
const metricValues = ['metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect {...defaultProps} value={metricValues} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
expect(onChange).not.toHaveBeenCalled();
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).toHaveTextContent('metric_a');
});
test('can drop a saved metric when disallow_adhoc_metrics', () => {
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_b']}
onChange={onChange}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
/>,
{ useDndKit: true, useRedux: true },
const metricValues = ['metric_b'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
useRedux: true,
},
);
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric.children[1]).toHaveTextContent('metric_a');
});
test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
const onChange = jest.fn();
render(
<DndMetricSelect
{...defaultProps}
value={['metric_b']}
onChange={onChange}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
/>,
{ useDndKit: true, useRedux: true },
const metricValues = ['metric_b'];
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ column_name: 'column_1', uuid: '3' }}
type={DndItemType.Column}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
useRedux: true,
},
);
// Non-saved metric -> rejected.
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_c' } as any,
});
expect(onChange).not.toHaveBeenCalled();
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableMetric = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
// Column type -> rejected when adhoc metrics are disallowed.
simulateDrop(captured, {
type: DndItemType.Column,
value: { column_name: 'column_a' } as any,
});
expect(onChange).not.toHaveBeenCalled();
const currentMetricSelection = currentMetric.children.length;
// Saved metric -> accepted.
simulateDrop(captured, {
type: DndItemType.Metric,
value: { metric_name: 'metric_a' } as any,
});
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('metric_c');
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('column_1');
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric).toHaveTextContent('metric_a');
});
test('title changes on custom SQL text change', async () => {
@@ -459,7 +477,7 @@ test('title changes on custom SQL text change', async () => {
multi
/>,
{
useDndKit: true,
useDnd: true,
useRedux: true,
},
);

View File

@@ -408,9 +408,6 @@ const DndMetricSelect = (props: any) => {
multi ? 2 : 1,
);
// Generate sortable type that matches MetricDefinitionValue's type
const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
return (
<div className="metrics-select">
<DndSelectLabel
@@ -421,8 +418,6 @@ const DndMetricSelect = (props: any) => {
ghostButtonText={ghostButtonText}
displayGhostButton={multi || value.length === 0}
onClickGhostButton={handleClickGhostButton}
sortableType={sortableType}
itemCount={value.length}
{...props}
/>
<AdhocMetricPopoverTrigger

View File

@@ -52,7 +52,7 @@ const MockChildren = () => {
};
test('renders with default props', () => {
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
});
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
const ghostButtonText = 'Ghost button text';
render(
<DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
{ useDndKit: true },
{ useDnd: true },
);
expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
});
@@ -69,13 +69,13 @@ test('renders values', () => {
const values = 'Values';
const valuesRenderer = () => <span>{values}</span>;
render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
useDndKit: true,
useDnd: true,
});
expect(screen.getByText(values)).toBeInTheDocument();
});
test('Handles ghost button click', () => {
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
userEvent.click(screen.getByText('Drop columns here or click'));
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
});
@@ -86,6 +86,7 @@ test('updates dropValidator on changes', () => {
<DndSelectLabel {...defaultProps} />
<MockChildren />
</ExploreContainer>,
{ useDnd: true },
);
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
'false',

View File

@@ -17,11 +17,7 @@
* under the License.
*/
import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useDrop } from 'react-dnd';
import { t } from '@apache-superset/core/translation';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
@@ -49,9 +45,6 @@ export type DndSelectLabelProps = {
displayGhostButton?: boolean;
onClickGhostButton: () => void;
isLoading?: boolean;
// For sortable items - the type string and count to generate sortable IDs
sortableType?: string;
itemCount?: number;
};
export default function DndSelectLabel({
@@ -59,49 +52,35 @@ export default function DndSelectLabel({
accept,
valuesRenderer,
isLoading,
sortableType,
itemCount = 0,
...props
}: DndSelectLabelProps) {
const canDropProp = props.canDrop;
const canDropValueProp = props.canDropValue;
const acceptTypes = useMemo(
() => (Array.isArray(accept) ? accept : [accept]),
[accept],
);
const dropValidator = useCallback(
(item: DatasourcePanelDndItem) =>
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
[canDropProp, canDropValueProp],
);
const { setNodeRef, isOver, active } = useDroppable({
id: `dropzone-${props.name}`,
disabled: isLoading,
data: {
accept: acceptTypes,
// Mirrors react-dnd's `canDrop`: the drop only fires when this returns
// true, so duplicate/selection gating is preserved post-migration.
canDrop: dropValidator,
onDrop: props.onDrop,
onDropValue: props.onDropValue,
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
accept: isLoading ? [] : accept,
drop: (item: DatasourcePanelDndItem) => {
props.onDrop(item);
props.onDropValue?.(item.value);
},
canDrop: dropValidator,
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
// Check if the active dragged item can be dropped here
const canDrop = useMemo(() => {
if (!active?.data.current) return false;
const activeData = active.data.current as { type: string; value: unknown };
if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
return dropValidator({
type: activeData.type as DndItemType,
value: activeData.value as DndItemValue,
});
}, [active, acceptTypes, dropValidator]);
const [, dispatch] = useContext(DropzoneContext);
const dispatch = useContext(DropzoneContext)[1];
useEffect(() => {
dispatch({ key: props.name, canDrop: dropValidator });
@@ -114,15 +93,6 @@ export default function DndSelectLabel({
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
// Generate sortable item IDs for SortableContext
const sortableItemIds = useMemo(() => {
if (!sortableType || itemCount === 0) return [];
return Array.from(
{ length: itemCount },
(_, i) => `sortable-${sortableType}-${i}`,
);
}, [sortableType, itemCount]);
function renderGhostButton() {
return (
<AddControlLabel
@@ -135,25 +105,8 @@ export default function DndSelectLabel({
);
}
// The actual drop is handled in ExploreDndContext's onDragEnd.
// Wrap values in SortableContext if sortable
const renderSortableValues = () => {
if (sortableItemIds.length > 0) {
return (
<SortableContext
items={sortableItemIds}
strategy={verticalListSortingStrategy}
>
{values}
</SortableContext>
);
}
return values;
};
return (
<div ref={setNodeRef}>
<div ref={datasourcePanelDrop}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
@@ -164,7 +117,7 @@ export default function DndSelectLabel({
isDragging={isDragging}
isLoading={isLoading}
>
{renderSortableValues()}
{values}
{displayGhostButton && renderGhostButton()}
</DndLabelsContainer>
</div>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { DndItemType } from 'src/explore/components/DndItemType';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
@@ -29,66 +29,35 @@ test('renders with default props', async () => {
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDndKit: true },
{ useDnd: true },
);
expect(container).toBeInTheDocument();
expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
});
test('renders label correctly', async () => {
render(
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Test Label"
/>,
{ useDndKit: true },
);
expect(await screen.findByText('Test Label')).toBeInTheDocument();
});
test('renders multiple options', async () => {
test('triggers onShiftOptions on drop', async () => {
const onShiftOptions = jest.fn();
render(
<>
<OptionWrapper
index={0}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option 1"
/>
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
onShiftOptions={onShiftOptions}
label="Option 1"
/>
<OptionWrapper
index={2}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
label="Option 2"
/>
</>,
{ useDndKit: true },
{ useDnd: true },
);
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(await screen.findByText('Option 2')).toBeInTheDocument();
});
test('calls clickClose when close button is clicked', async () => {
const clickClose = jest.fn();
render(
<OptionWrapper
index={1}
clickClose={clickClose}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDndKit: true },
);
const closeButton = await screen.findByRole('img', { name: 'close' });
closeButton.click();
expect(clickClose).toHaveBeenCalledWith(1);
fireEvent.dragStart(await screen.findByText('Option 1'));
fireEvent.drop(await screen.findByText('Option 2'));
expect(onShiftOptions).toHaveBeenCalled();
});

View File

@@ -16,9 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useRef } from 'react';
import {
useDrag,
useDrop,
DropTargetMonitor,
DragSourceMonitor,
} from 'react-dnd';
import { DragContainer } from 'src/explore/components/controls/OptionControls';
import {
OptionProps,
@@ -60,32 +64,62 @@ export default function OptionWrapper(
multiValueWarningMessage,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
data: {
const [{ isDragging }, drag] = useDrag({
item: {
type,
dragIndex: index,
onShiftOptions,
} as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
},
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const [, drop] = useDrop({
accept: type,
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset
? clientOffset.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onShiftOptions(dragIndex, hoverIndex);
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const shouldShowTooltip =
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
@@ -145,14 +179,10 @@ export default function OptionWrapper(
return null;
};
drag(drop(ref));
return (
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...rest}
>
<DragContainer ref={ref} {...rest}>
<Option
index={index}
clickClose={clickClose}

View File

@@ -1,120 +0,0 @@
/**
* 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 {
DroppableData,
resolveDragEnd,
} from 'src/explore/components/ExploreContainer/ExploreDndContext';
import { DndItemType } from 'src/explore/components/DndItemType';
import { DndItemValue } from 'src/explore/components/DatasourcePanel/types';
/**
* @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
* cannot meaningfully dispatch (it has no layout). To exercise drop behavior in
* unit tests we capture the `data` object a control registers via
* `useDroppable` and feed it through the same `resolveDragEnd` dispatcher the
* live `ExploreDndContextProvider` runs on drag end.
*
* Usage: spy on `useDroppable` with `captureDroppableData`, render the control,
* then call `simulateDrop` with the dragged item.
*/
export type CapturedDroppable = { current: DroppableData | undefined };
/**
* Returns a `jest.fn` mock implementation for `@dnd-kit/core`'s `useDroppable`
* that records the most recently registered droppable data into `captured`,
* while returning an inert droppable shape so the control still renders.
*/
export function captureDroppableData(captured: CapturedDroppable) {
return (args: { data?: DroppableData }) => {
captured.current = args?.data;
return {
setNodeRef: () => {},
isOver: false,
active: null,
rect: { current: null },
node: { current: null },
over: null,
};
};
}
/**
* Drives a single drag-and-drop of `item` onto the captured droppable through
* the production `resolveDragEnd` dispatcher.
*/
export function simulateDrop(
captured: CapturedDroppable,
item: { type: DndItemType; value: DndItemValue },
) {
resolveDragEnd(
{ id: 'drag-source', data: { current: item } },
{ id: 'dropzone', data: { current: captured.current ?? {} } },
);
}
export type SortableItemData = {
type: string;
dragIndex: number;
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
};
export type CapturedSortables = { items: SortableItemData[] };
/**
* Returns a `jest.fn` implementation for `@dnd-kit/sortable`'s `useSortable`
* that records each sortable item's registered data (carrying the reorder
* callbacks) into `captured`, while returning an inert sortable shape so the
* control still renders.
*/
export function captureSortableData(captured: CapturedSortables) {
return (args: { data?: SortableItemData }) => {
if (args?.data) {
captured.items.push(args.data);
}
return {
attributes: {},
listeners: {},
setNodeRef: () => {},
transform: null,
transition: undefined,
isDragging: false,
setActivatorNodeRef: () => {},
};
};
}
/**
* Drives an in-list reorder (drag item at `fromIndex` over item at `toIndex`)
* through the production `resolveDragEnd` dispatcher, using the reorder
* callbacks the control registered on its sortable items.
*/
export function simulateReorder(
captured: CapturedSortables,
fromIndex: number,
toIndex: number,
) {
const from = captured.items.find(i => i.dragIndex === fromIndex);
const to = captured.items.find(i => i.dragIndex === toIndex);
resolveDragEnd(
{ id: `from-${fromIndex}`, data: { current: from } },
{ id: `to-${toIndex}`, data: { current: to } },
);
}

View File

@@ -73,7 +73,7 @@ test('should render the control label', async () => {
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByTestId('remove-control-button');
const removeBtn = await screen.findByRole('button');
expect(removeBtn).toBeInTheDocument();
});

View File

@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import {
OptionControlLabel,
DragContainer,
@@ -43,7 +48,7 @@ const defaultProps = {
const setup = (overrides?: Record<string, any>) =>
render(<OptionControlLabel {...defaultProps} {...overrides} />, {
useDndKit: true,
useDnd: true,
});
test('should render', async () => {
@@ -83,7 +88,7 @@ test('should display a certification icon if saved metric is certified', async (
);
});
test('renders multiple labels', async () => {
test('triggers onMoveLabel on drop', async () => {
render(
<>
<OptionControlLabel
@@ -96,11 +101,15 @@ test('renders multiple labels', async () => {
index={2}
label={<span>Label 2</span>}
/>
,
</>,
{ useDndKit: true },
{ useDnd: true },
);
expect(await screen.findByText('Label 1')).toBeInTheDocument();
expect(await screen.findByText('Label 2')).toBeInTheDocument();
await waitFor(() => {
fireEvent.dragStart(screen.getByText('Label 1'));
fireEvent.drop(screen.getByText('Label 2'));
expect(defaultProps.onMoveLabel).toHaveBeenCalled();
});
});
test('renders DragContainer', () => {

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, ReactNode, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useRef, ReactNode } from 'react';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme, css, keyframes } from '@apache-superset/core/theme';
import { InfoTooltip, Icons, Tooltip } from '@superset-ui/core/components';
@@ -233,12 +233,9 @@ export const AddIconButton = styled.button`
}
`;
export interface SortableItemData {
type: string;
interface DragItem {
dragIndex: number;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
value?: savedMetricType | AdhocMetric;
type: string;
}
export const OptionControlLabel = ({
@@ -275,37 +272,73 @@ export const OptionControlLabel = ({
multi?: boolean;
}) => {
const theme = useTheme();
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const hasMetricName = savedMetric?.metric_name;
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
disabled: !multi,
data: {
const [, drop] = useDrop({
accept: type,
drop() {
if (!multi) {
return;
}
onDropLabel?.();
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!multi) {
return;
}
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset?.y
? clientOffset?.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onMoveLabel?.(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
item: {
type,
dragIndex: index,
onMoveLabel,
onDropLabel,
value: savedMetric?.metric_name ? savedMetric : adhocMetric,
} as SortableItemData,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getLabelContent = () => {
const shouldShowTooltip =
(!isDragging &&
@@ -390,14 +423,6 @@ export const OptionControlLabel = ({
</OptionControlContainer>
);
return (
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{getOptionControlContent()}
</DragContainer>
);
drag(drop(ref));
return <DragContainer ref={ref}>{getOptionControlContent()}</DragContainer>;
};

View File

@@ -243,7 +243,6 @@ export const accessTokenField = ({
validationErrors,
db,
isEditMode,
isValidating,
default_value,
description,
}: FieldPropTypes) => (
@@ -251,7 +250,6 @@ export const accessTokenField = ({
id="access_token"
name="access_token"
required={required}
isValidating={isValidating}
visibilityToggle={!isEditMode}
value={db?.parameters?.access_token}
validationMethods={{ onBlur: getValidation }}

View File

@@ -33,7 +33,6 @@ export const TableCatalog = ({
getValidation,
validationErrors,
db,
isValidating,
isPublic = true,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
@@ -54,7 +53,6 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-input"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
@@ -88,7 +86,6 @@ export const TableCatalog = ({
<ValidatedInput
className="catalog-name-url"
required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}

View File

@@ -49,13 +49,11 @@ export const validatedInputField = ({
validationErrors,
db,
field,
isValidating,
}: FieldPropTypes) => (
<ValidatedInput
id={field}
name={field}
required={required}
isValidating={isValidating}
value={db?.parameters?.[field as keyof DatabaseParameters]}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]}

View File

@@ -18,21 +18,18 @@
*/
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import {
Form,
FormLabel,
Col,
Row,
LabeledErrorBoundInput,
Icons,
Tooltip,
} from '@superset-ui/core/components';
import { Input } from '@superset-ui/core/components/Input';
import { Radio } from '@superset-ui/core/components/Radio';
import { DatabaseObject, CustomEventHandlerType } from '../types';
import { Icons } from '@superset-ui/core/components/Icons';
import { DatabaseObject, FieldPropTypes } from '../types';
import { AuthType } from '.';
const StyledDiv = styled.div`
@@ -51,73 +48,50 @@ const StyledFormItem = styled(Form.Item)`
margin-bottom: 0 !important;
`;
interface SSHTunnelFormProps {
db: DatabaseObject | null;
onSSHTunnelParametersChange: CustomEventHandlerType;
setSSHTunnelLoginMethod: (method: AuthType) => void;
isValidating?: boolean;
validationErrors?: JsonObject | null;
getValidation: () => void;
}
const StyledInputPassword = styled(Input.Password)`
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
`;
const SSHTunnelForm = ({
db,
onSSHTunnelParametersChange,
setSSHTunnelLoginMethod,
isValidating = false,
validationErrors,
getValidation,
}: SSHTunnelFormProps) => {
}: {
db: DatabaseObject | null;
onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange'];
setSSHTunnelLoginMethod: (method: AuthType) => void;
}) => {
const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password);
const sshErrors = validationErrors?.ssh_tunnel || {};
const sshSectionError = sshErrors?._error;
return (
<Form>
{sshSectionError && (
<StyledRow gutter={16}>
<Col xs={24}>
<Alert
type="error"
showIcon
message={sshSectionError}
data-test="ssh-tunnel-section-error"
/>
</Col>
</StyledRow>
)}
<StyledRow gutter={16}>
<Col xs={24} md={12}>
<StyledDiv>
<LabeledErrorBoundInput
id="server_address"
<FormLabel htmlFor="server_address" required>
{t('SSH Host')}
</FormLabel>
<Input
name="server_address"
label={t('SSH Host')}
required
type="text"
placeholder={t('e.g. 127.0.0.1')}
value={db?.ssh_tunnel?.server_address || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_address}
isValidating={isValidating}
data-test="ssh-tunnel-server_address-input"
/>
</StyledDiv>
</Col>
<Col xs={24} md={12}>
<StyledDiv>
<LabeledErrorBoundInput
id="server_port"
<FormLabel htmlFor="server_port" required>
{t('SSH Port')}
</FormLabel>
<Input
name="server_port"
label={t('SSH Port')}
required
placeholder={t('22')}
type="number"
value={db?.ssh_tunnel?.server_port}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_port}
isValidating={isValidating}
data-test="ssh-tunnel-server_port-input"
/>
</StyledDiv>
@@ -126,17 +100,15 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<LabeledErrorBoundInput
id="username"
<FormLabel htmlFor="username" required>
{t('Username')}
</FormLabel>
<Input
name="username"
label={t('Username')}
required
type="text"
placeholder={t('e.g. Analytics')}
value={db?.ssh_tunnel?.username || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.username}
isValidating={isValidating}
data-test="ssh-tunnel-username-input"
/>
</StyledDiv>
@@ -176,20 +148,16 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<LabeledErrorBoundInput
id="password"
<FormLabel htmlFor="password" required>
{t('SSH Password')}
</FormLabel>
<StyledInputPassword
name="password"
label={t('SSH Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.password}
isValidating={isValidating}
data-test="ssh-tunnel-password-input"
iconRender={(visible: boolean) =>
iconRender={visible =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />
@@ -214,47 +182,30 @@ const SSHTunnelForm = ({
<FormLabel htmlFor="private_key" required>
{t('Private Key')}
</FormLabel>
<StyledFormItem
validateStatus={
isValidating
? 'validating'
: sshErrors?.private_key
? 'error'
: 'success'
}
help={sshErrors?.private_key}
hasFeedback={isValidating || !!sshErrors?.private_key}
>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
onBlur={getValidation}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledFormItem>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledDiv>
</Col>
</StyledRow>
<StyledRow gutter={16}>
<Col xs={24}>
<StyledDiv>
<LabeledErrorBoundInput
id="private_key_password"
<FormLabel htmlFor="private_key_password" required>
{t('Private Key Password')}
</FormLabel>
<StyledInputPassword
name="private_key_password"
label={t('Private Key Password')}
required
visibilityToggle
placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.private_key_password || ''}
onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.private_key_password}
isValidating={isValidating}
data-test="ssh-tunnel-private_key_password-input"
iconRender={(visible: boolean) =>
iconRender={visible =>
visible ? (
<Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined />

View File

@@ -1212,40 +1212,26 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
fireEvent.change(SSHTunnelServerAddressInput, {
target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
fireEvent.change(SSHTunnelServerPortInput, {
target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
fireEvent.change(SSHTunnelUsernameInput, {
target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
fireEvent.change(SSHTunnelPasswordInput, {
target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
});
test('properly interacts with SSH Tunnel form textboxes', async () => {
@@ -1264,40 +1250,26 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
fireEvent.change(SSHTunnelServerAddressInput, {
target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input',
);
expect(SSHTunnelServerPortInput).toHaveValue(null);
fireEvent.change(SSHTunnelServerPortInput, {
target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
userEvent.type(SSHTunnelServerPortInput, '22');
expect(SSHTunnelServerPortInput).toHaveValue(22);
const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input',
);
expect(SSHTunnelUsernameInput).toHaveValue('');
fireEvent.change(SSHTunnelUsernameInput, {
target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
userEvent.type(SSHTunnelUsernameInput, 'test');
expect(SSHTunnelUsernameInput).toHaveValue('test');
const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input',
);
expect(SSHTunnelPasswordInput).toHaveValue('');
fireEvent.change(SSHTunnelPasswordInput, {
target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
userEvent.type(SSHTunnelPasswordInput, 'pass');
expect(SSHTunnelPasswordInput).toHaveValue('pass');
});
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
@@ -1392,10 +1364,7 @@ describe('DatabaseModal', () => {
}),
);
// Wait for step 2 to render
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const textboxes = screen.getAllByRole('textbox');
const hostField = textboxes[0];
const portField = screen.getByRole('spinbutton');
const databaseNameField = textboxes[1];
@@ -1411,20 +1380,15 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeDisabled();
fireEvent.change(hostField, { target: { value: 'localhost' } });
fireEvent.blur(hostField);
fireEvent.change(portField, { target: { value: '5432' } });
fireEvent.blur(portField);
fireEvent.change(databaseNameField, { target: { value: 'postgres' } });
fireEvent.blur(databaseNameField);
fireEvent.change(usernameField, { target: { value: 'testdb' } });
fireEvent.blur(usernameField);
fireEvent.change(passwordField, { target: { value: 'demoPassword' } });
fireEvent.blur(passwordField);
userEvent.type(hostField, 'localhost');
userEvent.type(portField, '5432');
userEvent.type(databaseNameField, 'postgres');
userEvent.type(usernameField, 'testdb');
userEvent.type(passwordField, 'demoPassword');
await waitFor(() => expect(connectButton).toBeEnabled());
await waitFor(() => expect(portField).toHaveValue(5432));
expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument();
expect(hostField).toHaveValue('localhost');
expect(portField).toHaveValue(5432);
expect(databaseNameField).toHaveValue('postgres');
@@ -1433,48 +1397,10 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeEnabled();
userEvent.click(connectButton);
// Verify that validation was called during the form interaction
// Note: With the optimized validation, redundant calls on the same db state are skipped
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toBeGreaterThan(0);
});
});
test('does not fire redundant validation on blur when db has not changed', async () => {
setup();
userEvent.click(
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
// Type a value and blur - should trigger validation
fireEvent.change(hostField, { target: { value: 'localhost' } });
fireEvent.blur(hostField);
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
// Blur again without changing the value - should NOT trigger another validation
fireEvent.focus(hostField);
fireEvent.blur(hostField);
// Wait a tick to ensure no additional calls are made
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
).toEqual(5);
});
});
});

View File

@@ -668,7 +668,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
hasValidated,
setHasValidated,
] = useDatabaseValidation();
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
const [showCTAbtns, setShowCTAbtns] = useState(false);
const [dbName, setDbName] = useState('');
@@ -776,7 +775,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const handleClearValidationErrors = useCallback(() => {
setValidationErrors(null);
setHasValidated(false);
lastValidatedDbSnapshotRef.current = null;
clearError();
}, [setValidationErrors, setHasValidated, clearError]);
@@ -853,16 +851,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange],
);
const handleTextChange = useCallback(
({ target }: { target: HTMLInputElement }) => {
onChange(ActionType.TextChange, {
name: target.name,
value: target.value,
});
},
[onChange],
);
const handleChangeWithValidation = useCallback(
(
actionType: ActionType,
@@ -874,21 +862,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange, handleClearValidationErrors],
);
const getBlurValidation = useCallback(async () => {
const currentDbSnapshot = JSON.stringify(db);
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
return [];
}
const result = await getValidation(db);
// Only cache after a request that produced a usable response. ``null``
// signals an unexpected/network failure, in which case we leave the
// snapshot untouched so the next blur retries.
if (result !== null) {
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
}
return result;
}, [db, getValidation]);
const onClose = () => {
setDB({ type: ActionType.Reset });
setHasConnectedDb(false);
@@ -967,15 +940,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}
const errors = await getValidation(dbToUpdate, true);
// ``getValidation`` returns ``[]`` on success, a field-keyed object
// for blocking errors (e.g. the duplicate ``database_name`` check),
// and ``null`` for stale or unexpected responses. During save we
// cannot proceed without a usable result, so treat ``null`` as
// blocking too — only ``[]`` is a clean pass.
const hasReturnedErrors =
errors === null ||
(Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors));
if (!isEmpty(validationErrors) || hasReturnedErrors) {
if (!isEmpty(validationErrors) || errors?.length) {
addDangerToast(
t('Connection failed, please check your connection settings.'),
);
@@ -1882,6 +1847,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
name: target.name,
value: target.value,
});
handleClearValidationErrors();
}}
setSSHTunnelLoginMethod={(method: AuthType) =>
setDB({
@@ -1889,9 +1855,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
payload: { login_method: method },
})
}
isValidating={isValidating}
validationErrors={validationErrors}
getValidation={getBlurValidation}
/>
);
@@ -1967,8 +1930,13 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
});
}}
onParametersChange={handleParametersChange}
onChange={handleTextChange}
getValidation={getBlurValidation}
onChange={({ target }: { target: HTMLInputElement }) =>
handleChangeWithValidation(ActionType.TextChange, {
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
clearValidationErrors={handleClearValidationErrors}

View File

@@ -252,7 +252,9 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
// Only return permission id=10, not id=20
return Promise.resolve({
@@ -296,7 +298,9 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
return Promise.reject(new Error('network error'));
}
@@ -367,9 +371,7 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
) {
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
return Promise.resolve({
json: {
result: roleA.permission_ids.map(pid => ({
@@ -380,9 +382,7 @@ describe('RoleListEditModal', () => {
},
});
}
if (
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
) {
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({

View File

@@ -33,12 +33,7 @@ import { ensureAppRoot } from '../utils/pathUtils';
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
import type { QueryEditor } from '../SqlLab/types';
type LogEventSource =
| 'dashboard'
| 'embedded_dashboard'
| 'explore'
| 'sqlLab'
| 'slice';
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
interface LogEventData {
source?: LogEventSource;

View File

@@ -819,13 +819,16 @@ export function useDatabaseValidation() {
);
const [isValidating, setIsValidating] = useState(false);
const [hasValidated, setHasValidated] = useState(false);
const latestRequestIdRef = useRef(0);
const getValidation = useCallback(
async (database: Partial<DatabaseObject> | null, onCreate = false) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
if (database?.parameters?.ssh) {
setValidationErrors(null);
setIsValidating(false);
setHasValidated(true);
return Promise.resolve([]);
}
setIsValidating(true);
try {
@@ -834,9 +837,6 @@ export function useDatabaseValidation() {
body: JSON.stringify(transformDB(database)),
headers: { 'Content-Type': 'application/json' },
});
// Stale responses return ``null`` so callers can tell the result
// apart from a real, current outcome and skip caching it.
if (!isLatest()) return null;
setValidationErrors(null);
setIsValidating(false);
setHasValidated(true);
@@ -845,18 +845,12 @@ export function useDatabaseValidation() {
if (typeof error.json === 'function') {
return error.json().then(({ errors = [] }) => {
const parsedErrors = errors
.filter((err: { error_type: string; extra?: JsonObject }) => {
.filter((err: { error_type: string }) => {
const allowed = [
'CONNECTION_MISSING_PARAMETERS_ERROR',
'CONNECTION_ACCESS_DENIED_ERROR',
'INVALID_PAYLOAD_SCHEMA_ERROR',
];
// SSH-tunnel section errors carry their own ``ssh_tunnel``
// marker and need to surface during blur validation too,
// otherwise feature-gate failures become invisible
// blockers (the save guard would still trip but with no
// hint about why).
if (err.extra?.ssh_tunnel) return true;
return allowed.includes(err.error_type) || onCreate;
})
.reduce((acc: JsonObject, err2: any) => {
@@ -872,28 +866,6 @@ export function useDatabaseValidation() {
return acc;
}
if (extra?.ssh_tunnel) {
// Field-level errors come in via ``extra.missing``;
// section-level errors (e.g. feature flag disabled) do
// not name a specific field, so preserve the server
// message under a reserved ``_error`` key so the SSH
// form can render it instead of silently dropping it.
const missingFields = extra.missing ?? [];
acc.ssh_tunnel = {
...acc.ssh_tunnel,
...Object.fromEntries(
missingFields.map((field: string) => [
field,
'This is a required field',
]),
),
...(missingFields.length === 0 && message
? { _error: message }
: {}),
};
return acc;
}
if (extra?.invalid) {
extra.invalid.forEach((field: string) => {
acc[field] = message;
@@ -913,7 +885,6 @@ export function useDatabaseValidation() {
return acc;
}, {});
if (!isLatest()) return null;
setValidationErrors(parsedErrors);
setIsValidating(false);
setHasValidated(true);
@@ -922,11 +893,9 @@ export function useDatabaseValidation() {
}
console.error('Unexpected error during validation:', error);
if (isLatest()) {
setIsValidating(false);
setHasValidated(true);
}
return null;
setIsValidating(false);
setHasValidated(true);
return {};
}
},
[setValidationErrors],

View File

@@ -19,7 +19,6 @@ from typing import Any, Optional
from flask_babel import gettext as __
from superset import is_feature_enabled
from superset.commands.base import BaseCommand
from superset.commands.database.exceptions import (
DatabaseOfflineError,
@@ -34,7 +33,6 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.extensions import event_logger
from superset.models.core import Database
from superset.utils import json
from superset.utils.ssh_tunnel import unmask_password_info
BYPASS_VALIDATION_ENGINES = {"bigquery", "datastore", "snowflake"}
@@ -44,23 +42,14 @@ class ValidateDatabaseParametersCommand(BaseCommand):
self._properties = properties.copy()
self._model: Optional[Database] = None
def run(self) -> None: # noqa: C901
def run(self) -> None:
self.validate()
engine = self._properties["engine"]
driver = self._properties.get("driver")
if engine in BYPASS_VALIDATION_ENGINES:
# Skip engines that are only validated onCreate, but still surface
# database_name uniqueness and SSH tunnel field errors so the
# progressive validation flow stays consistent across engines.
errors: list[SupersetError] = []
if database_name_error := self._get_database_name_error():
errors.append(database_name_error)
errors.extend(self._get_ssh_tunnel_errors())
if errors:
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
# Skip engines that are only validated onCreate
return
engine_spec = get_engine_spec(engine, driver)
@@ -76,17 +65,8 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
# perform initial validation (host, port, database, username)
# perform initial validation
errors = engine_spec.validate_parameters(self._properties) # type: ignore
# Collect database_name errors along with parameter errors
if database_name_error := self._get_database_name_error():
errors.append(database_name_error)
# Collect SSH tunnel errors
ssh_tunnel_errors = self._get_ssh_tunnel_errors()
errors.extend(ssh_tunnel_errors)
if errors:
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
@@ -112,23 +92,11 @@ class ValidateDatabaseParametersCommand(BaseCommand):
)
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
# Forward the SSH tunnel into the connection test so that
# tunnel-only databases are reached through the tunnel rather
# than directly, mirroring the existing test_connection flow.
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
self._model.ssh_tunnel,
)
database = DatabaseDAO.build_db_for_connection_test(
server_cert=self._properties.get("server_cert", ""),
extra=self._properties.get("extra", "{}"),
impersonate_user=self._properties.get("impersonate_user", False),
encrypted_extra=json.dumps(encrypted_extra),
ssh_tunnel=ssh_tunnel_properties,
)
database.set_sqlalchemy_uri(sqlalchemy_uri)
database.db_engine_spec.mutate_db_for_connection_test(database)
@@ -170,116 +138,6 @@ class ValidateDatabaseParametersCommand(BaseCommand):
),
)
def _load_model(self) -> None:
"""Load the existing database model if updating."""
def validate(self) -> None:
if (database_id := self._properties.get("id")) is not None:
self._model = DatabaseDAO.find_by_id(database_id)
def _get_database_name_error(self) -> Optional[SupersetError]:
"""Check for duplicate database name and return error if found."""
database_id = self._properties.get("id")
if database_name := self._properties.get("database_name"):
is_unique = (
DatabaseDAO.validate_update_uniqueness(database_id, database_name)
if database_id is not None
else DatabaseDAO.validate_uniqueness(database_name)
)
if not is_unique:
return SupersetError(
message=__("A database with the same name already exists."),
error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["database_name"]},
)
return None
def validate(self) -> None:
"""Load the model in preparation for run()."""
self._load_model()
def _get_ssh_tunnel_errors(self) -> list[SupersetError]:
"""Validate SSH tunnel fields and return list of errors."""
errors: list[SupersetError] = []
ssh_tunnel = self._properties.get("ssh_tunnel") or {}
parameters = self._properties.get("parameters") or {}
# ``parameters.ssh`` is the UI toggle. When it's explicitly false the
# user has turned SSH off and any stale ``ssh_tunnel`` payload should
# be ignored — otherwise toggling off after partially filling the
# tunnel form would still trip validation. When the toggle is absent
# (older callers / save-time payloads) fall back to inferring from
# the presence of an ``ssh_tunnel`` object.
if "ssh" in parameters:
if not parameters.get("ssh"):
return errors
ssh_enabled = True
else:
ssh_enabled = bool(ssh_tunnel)
if not ssh_enabled:
return errors
if not is_feature_enabled("SSH_TUNNELING"):
errors.append(
SupersetError(
message=__("SSH Tunneling is not enabled"),
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
extra={"ssh_tunnel": True},
)
)
return errors
if not parameters.get("port"):
# ``port`` is a database parameter (not a tunnel field), so it
# surfaces via the top-level ``missing`` path and lands on the
# database port input rather than the SSH tunnel section.
errors.append(
SupersetError(
message=__(
"A database port is required when connecting via SSH Tunnel."
),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": ["port"]},
)
)
# Required fields on the tunnel itself
required_fields = ["server_address", "server_port", "username"]
missing = [f for f in required_fields if not ssh_tunnel.get(f)]
if missing:
errors.append(
SupersetError(
message=__(
"One or more parameters are missing: %(missing)s",
missing=", ".join(missing),
),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": missing, "ssh_tunnel": True},
)
)
# Either password or private_key is required. The active login
# method is a client-side toggle, so flag both fields as missing —
# the modal renders only one pane at a time and will surface the
# error on whichever field the user can see.
has_password = bool(ssh_tunnel.get("password"))
has_private_key = bool(ssh_tunnel.get("private_key"))
if not has_password and not has_private_key:
errors.append(
SupersetError(
message=__("Must provide credentials for the SSH Tunnel"),
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={
"missing": ["password", "private_key"],
"ssh_tunnel": True,
},
)
)
return errors

View File

@@ -443,24 +443,6 @@ class DatabaseValidateParametersSchema(Schema):
required=True,
metadata={"description": configuration_method_description},
)
ssh_tunnel = fields.Nested("DatabaseSSHTunnelValidation", allow_none=True)
class DatabaseSSHTunnelValidation(Schema):
"""SSH Tunnel schema for validation.
Allows partial data without strict authentication requirements.
"""
id = fields.Integer(
allow_none=True, metadata={"description": "SSH Tunnel ID (for updates)"}
)
server_address = fields.String(allow_none=True)
server_port = fields.Integer(allow_none=True)
username = fields.String(allow_none=True)
password = fields.String(required=False, allow_none=True)
private_key = fields.String(required=False, allow_none=True)
private_key_password = fields.String(required=False, allow_none=True)
class DatabaseSSHTunnel(Schema):

View File

@@ -157,14 +157,31 @@ def validate_chart_dataset(
)
def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> str:
"""Generate an explore link for the given dataset and form data."""
def generate_explore_link(
dataset_id: int | str,
form_data: Dict[str, Any],
prefer_permalink: bool = True,
) -> str:
"""Generate an explore link for the given dataset and form data.
Prefers a durable explore permalink (DB-backed key-value store, does not
expire) over an ephemeral form_data_key (Redis cache, expires in ~24h).
Falls back to the form_data_key approach if permalink creation fails, then
to a plain dataset URL as a last resort.
Set ``prefer_permalink=False`` for callers that depend on a ``form_data_key``
in the returned URL (e.g. preview flows that extract and re-cache the key);
this skips the permalink path and returns an ``/explore/?form_data_key=...``
URL directly.
"""
from sqlalchemy.exc import SQLAlchemyError
from superset.commands.exceptions import CommandException
from superset.commands.explore.form_data.parameters import CommandParameters
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
from superset.daos.dataset import DatasetDAO
from superset.exceptions import SupersetException
from superset.explore.permalink.exceptions import ExplorePermalinkCreateFailedError
from superset.mcp_service.commands.create_form_data import (
MCPCreateFormDataCommand,
)
@@ -200,7 +217,27 @@ def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> s
"datasource": f"{numeric_dataset_id}__table",
}
# Try to create form_data in cache using MCP-specific CreateFormDataCommand
# Try durable permalink first (DB-backed key-value store, does not expire).
# CreateExplorePermalinkCommand wraps its internal failures (encode/create/
# SQLAlchemy errors) into ExplorePermalinkCreateFailedError, so catch only
# those expected modes here — letting programming errors (TypeError, etc.)
# surface instead of being silently masked by the form_data_key fallback.
# Callers that need a form_data_key URL opt out via prefer_permalink=False.
if prefer_permalink:
try:
state = {"formData": form_data_with_datasource}
permalink_key = CreateExplorePermalinkCommand(state=state).run()
return f"{base_url}/explore/p/{permalink_key}/"
except (
ExplorePermalinkCreateFailedError,
SQLAlchemyError,
) as permalink_e:
logger.debug(
"Permalink generation failed, falling back to form_data_key: %s",
permalink_e,
)
# Fall back to ephemeral form_data_key (Redis-backed cache)
cmd_params = CommandParameters(
datasource_type=DatasourceType.TABLE,
datasource_id=numeric_dataset_id,
@@ -208,23 +245,18 @@ def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> s
tab_id=None,
form_data=json.dumps(form_data_with_datasource),
)
# Create the form_data cache entry and get the key
form_data_key = MCPCreateFormDataCommand(cmd_params).run()
# Return URL with just the form_data_key
return f"{base_url}/explore/?form_data_key={form_data_key}"
except (
CommandException,
SupersetException,
SQLAlchemyError,
KeyError,
ValueError,
AttributeError,
TypeError,
) as e:
# Fallback to basic explore URL with numeric ID if available
# Fallback to basic explore URL with numeric ID if available. Only the
# expected failure modes of dataset lookup / form_data creation are caught
# here; programming errors propagate to the tool handler so they aren't
# silently masked behind a fallback URL.
logger.debug("Explore link generation fallback due to: %s", e)
if numeric_dataset_id is not None:
return (

View File

@@ -527,7 +527,9 @@ async def generate_chart( # noqa: C901
# Generate explore link with cached form_data for preview-only mode
from superset.mcp_service.chart.chart_utils import generate_explore_link
explore_url = generate_explore_link(request.dataset_id, form_data)
explore_url = generate_explore_link(
request.dataset_id, form_data, prefer_permalink=False
)
await ctx.debug("Generated explore link: explore_url=%s" % (explore_url,))
# Extract form_data_key from the explore URL

View File

@@ -240,8 +240,11 @@ def update_chart_preview( # noqa: C901
"api_version": "v1",
}
# Generate new explore link with updated form_data
explore_url = generate_explore_link(request.dataset_id, new_form_data)
# Generate new explore link with updated form_data. This preview flow
# extracts and re-caches the form_data_key, so force that URL shape.
explore_url = generate_explore_link(
request.dataset_id, new_form_data, prefer_permalink=False
)
# Extract new form_data_key from the explore URL
new_form_data_key = extract_form_data_key_from_url(explore_url)

View File

@@ -30,7 +30,9 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.auth import has_dataset_access
from superset.mcp_service.chart.chart_helpers import extract_form_data_key_from_url
from superset.mcp_service.chart.chart_helpers import (
extract_form_data_key_from_url,
)
from superset.mcp_service.chart.chart_utils import (
generate_explore_link as generate_url,
get_table_chart_type_label,
@@ -40,6 +42,9 @@ from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.schemas import (
GenerateExploreLinkRequest,
)
from superset.mcp_service.utils.url_utils import (
extract_permalink_key_from_url,
)
logger = logging.getLogger(__name__)
@@ -135,6 +140,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
@@ -154,6 +160,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
@@ -178,6 +185,7 @@ async def generate_explore_link(
return {
"url": default_url,
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": None,
@@ -209,7 +217,7 @@ async def generate_explore_link(
# Add datasource to form_data for consistency with generate_chart
# Only set if not already present to avoid overwriting
if "datasource" not in form_data:
form_data["datasource"] = f"{request.dataset_id}__table"
form_data["datasource"] = f"{dataset.id}__table"
await ctx.debug(
"Form data generated with keys: %s, has_viz_type=%s, has_datasource=%s"
@@ -248,6 +256,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": form_data,
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": error_payload,
@@ -262,19 +271,23 @@ async def generate_explore_link(
dataset_id=request.dataset_id, form_data=form_data
)
# Extract form_data_key from the explore URL
form_data_key = extract_form_data_key_from_url(explore_url)
# Extract permalink_key (durable) or fall back to form_data_key (ephemeral)
permalink_key = extract_permalink_key_from_url(explore_url)
form_data_key = (
extract_form_data_key_from_url(explore_url) if not permalink_key else None
)
await ctx.report_progress(4, 4, "URL generation complete")
await ctx.info(
"Explore link generated successfully: url_length=%s, dataset_id=%s, "
"form_data_key=%s"
% (len(explore_url or ""), request.dataset_id, form_data_key)
"permalink_key=%s, form_data_key=%s"
% (len(explore_url or ""), request.dataset_id, permalink_key, form_data_key)
)
return {
"url": explore_url,
"form_data": form_data,
"permalink_key": permalink_key,
"form_data_key": form_data_key,
"chart_type_label": get_table_chart_type_label(form_data.get("viz_type")),
"error": None,
@@ -293,6 +306,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": f"Failed to generate explore link: {str(e)}",

View File

@@ -57,6 +57,22 @@ def get_superset_base_url() -> str:
return default_url
def extract_permalink_key_from_url(url: str | None) -> str | None:
"""Extract the permalink key from an explore permalink URL.
Matches the /explore/p/<key>/ pattern produced by
CreateExplorePermalinkCommand. Returns the key, or None if the URL
does not follow that pattern.
"""
if not url:
return None
path = urlparse(url).path
parts = [p for p in path.split("/") if p]
if len(parts) >= 3 and parts[-3] == "explore" and parts[-2] == "p":
return parts[-1]
return None
def get_mcp_service_url() -> str:
"""
Get the MCP service base URL where screenshot endpoints are served.

View File

@@ -205,487 +205,3 @@ def test_command_with_oauth2_not_configured(mocker: MockerFixture) -> None:
extra={"engine_name": "gsheets"},
)
]
def test_command_duplicate_database_name(mocker: MockerFixture) -> None:
"""
Validation surfaces a duplicate-name error for a new database with a
name already in use.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = False
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"database_name": "duplicate",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.error_type == SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR
and err.extra is not None
and err.extra.get("invalid") == ["database_name"]
for err in excinfo.value.errors
)
def test_command_duplicate_database_name_on_update(mocker: MockerFixture) -> None:
"""
Validation uses ``validate_update_uniqueness`` when an ``id`` is provided.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.find_by_id.return_value = mocker.MagicMock()
DatabaseDAO.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"id": 1,
"engine": "postgresql",
"database_name": "existing",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError):
command.run()
DatabaseDAO.validate_update_uniqueness.assert_called_once_with(1, "existing")
def test_command_duplicate_database_name_bypass_engine(
mocker: MockerFixture,
) -> None:
"""
Bypass engines (e.g. ``bigquery``) still validate database name uniqueness.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = False
properties = {
"engine": "bigquery",
"database_name": "duplicate",
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert excinfo.value.errors[0].error_type == (
SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR
)
def test_validate_ssh_tunnel_feature_disabled(mocker: MockerFixture) -> None:
"""
Enabling SSH tunnel without the feature flag surfaces a field-level
SupersetError tagged with ``ssh_tunnel`` so the modal can map it back
to the SSH tunnel section instead of throwing a hard 400 toast.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=False,
)
properties = {
"engine": "postgresql",
"ssh_tunnel": {"server_address": "localhost"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_validate_ssh_tunnel_missing_db_port(mocker: MockerFixture) -> None:
"""
SSH tunneling requires a database port; the error is surfaced via the
top-level ``missing`` extra so it lands on the database port input.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"ssh_tunnel": {"server_address": "localhost"},
"parameters": {"host": "localhost"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None
and "port" in (err.extra.get("missing") or [])
and not err.extra.get("ssh_tunnel")
for err in excinfo.value.errors
)
def test_get_ssh_tunnel_errors_missing_required_fields(
mocker: MockerFixture,
) -> None:
"""
SSH tunnel collects missing required fields (server_address, server_port,
username) and missing credentials.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None
and err.extra.get("ssh_tunnel") is True
and err.extra.get("missing") == ["server_port", "username"]
for err in excinfo.value.errors
)
assert any(
err.extra is not None
and err.extra.get("ssh_tunnel") is True
and err.extra.get("missing") == ["password", "private_key"]
for err in excinfo.value.errors
)
def test_get_ssh_tunnel_errors_unencrypted_private_key_is_valid(
mocker: MockerFixture,
) -> None:
"""
An unencrypted private key (no ``private_key_password``) is a valid
SSH tunnel credential — validation should not flag it as missing.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {
"server_address": "ssh.example.com",
"server_port": 22,
"username": "ssh-user",
"private_key": "----- KEY -----",
},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_ssh_tunnel_forwarded_to_connection_test(
mocker: MockerFixture,
) -> None:
"""
The SSH tunnel payload is forwarded into the connection test so
tunnel-only databases are reached through the tunnel rather than
pinged directly.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
ssh_tunnel = {
"server_address": "ssh.example.com",
"server_port": 22,
"username": "ssh-user",
"password": "secret",
}
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": ssh_tunnel,
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
DatabaseDAO.build_db_for_connection_test.assert_called_once()
assert (
DatabaseDAO.build_db_for_connection_test.call_args.kwargs["ssh_tunnel"]
== ssh_tunnel
)
def test_get_ssh_tunnel_errors_skipped_when_parameters_ssh_false(
mocker: MockerFixture,
) -> None:
"""
An explicit ``parameters.ssh == False`` is authoritative and skips SSH
tunnel validation even when a stale ``ssh_tunnel`` object is still in
the payload — otherwise toggling SSH off after partial entry would
leave hidden validation errors blocking save.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"database_name": "ok",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
"ssh": False,
},
# Stale partial tunnel payload from before the user toggled off:
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_get_ssh_tunnel_errors_skipped_when_not_enabled(
mocker: MockerFixture,
) -> None:
"""
SSH tunnel validation is a no-op when ssh is not enabled and no tunnel
is provided.
"""
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
database = mocker.MagicMock()
with database.get_sqla_engine() as engine:
engine.dialect.do_ping.return_value = True
DatabaseDAO.build_db_for_connection_test.return_value = database
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"),
unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
),
)
properties = {
"engine": "postgresql",
"database_name": "ok",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
}
command = ValidateDatabaseParametersCommand(properties)
command.run()
def test_bypass_engine_surfaces_ssh_tunnel_errors(mocker: MockerFixture) -> None:
"""
Bypass engines also surface SSH tunnel field errors so the progressive
validation flow stays consistent across engine types.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
DatabaseDAO = mocker.patch( # noqa: N806
"superset.commands.database.validate.DatabaseDAO"
)
DatabaseDAO.validate_uniqueness.return_value = True
properties = {
"engine": "snowflake",
"database_name": "ok",
"parameters": {"port": 443},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_validate_ssh_tunnel_feature_disabled_via_parameters_ssh(
mocker: MockerFixture,
) -> None:
"""
The SSH feature-flag guard fires when the UI marks ``parameters.ssh``
even if ``ssh_tunnel`` itself is empty during progressive validation.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=False,
)
properties = {
"engine": "postgresql",
"parameters": {"host": "localhost", "port": 5432, "ssh": True},
"ssh_tunnel": {},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
assert any(
err.extra is not None and err.extra.get("ssh_tunnel") is True
for err in excinfo.value.errors
)
def test_ssh_tunnel_missing_message_is_interpolated(
mocker: MockerFixture,
) -> None:
"""
The translated ``parameters are missing`` message is interpolated with
the actual missing fields rather than the raw ``%(missing)s`` token.
"""
mocker.patch(
"superset.commands.database.validate.is_feature_enabled",
return_value=True,
)
mocker.patch(
"superset.commands.database.validate.get_engine_spec",
return_value=mocker.MagicMock(
validate_parameters=mocker.MagicMock(return_value=[]),
),
)
properties = {
"engine": "postgresql",
"parameters": {
"host": "localhost",
"port": 5432,
"username": "u",
"database": "d",
},
"ssh_tunnel": {"server_address": "ssh.example.com"},
}
command = ValidateDatabaseParametersCommand(properties)
with pytest.raises(InvalidParametersError) as excinfo:
command.run()
missing_field_messages = [
err.message
for err in excinfo.value.errors
if err.extra is not None
and err.extra.get("missing")
and err.extra.get("ssh_tunnel") # noqa: E501
]
assert missing_field_messages
assert all("%(missing)s" not in msg for msg in missing_field_messages)
assert any("server_port" in msg for msg in missing_field_messages)

View File

@@ -1176,13 +1176,25 @@ class TestGenerateExploreLink:
self, mock_command, mock_get_base_url
) -> None:
"""Test generate_explore_link creates form_data_key when dataset exists"""
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_get_base_url.return_value = "http://localhost:9001"
mock_command.return_value.run.return_value = "test_form_data_key"
# Mock dataset exists
# Mock dataset exists; force the durable-permalink path to fail so the
# function falls back to the ephemeral form_data_key.
mock_dataset = type("Dataset", (), {"id": 123})()
with patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
with (
patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
),
patch(
"superset.commands.explore.permalink.create."
"CreateExplorePermalinkCommand.run",
side_effect=ExplorePermalinkCreateFailedError("permalink unavailable"),
),
):
result = generate_explore_link(123, {"viz_type": "table"})
@@ -1191,6 +1203,36 @@ class TestGenerateExploreLink:
)
mock_command.assert_called_once()
@patch("superset.mcp_service.chart.chart_utils.get_superset_base_url")
@patch("superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand")
def test_generate_explore_link_prefer_permalink_false(
self, mock_command, mock_get_base_url
) -> None:
"""prefer_permalink=False skips the permalink path and returns a
form_data_key URL, so preview callers that re-parse the key keep working."""
mock_get_base_url.return_value = "http://localhost:9001"
mock_command.return_value.run.return_value = "test_form_data_key"
mock_dataset = type("Dataset", (), {"id": 123})()
with (
patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
),
patch(
"superset.commands.explore.permalink.create."
"CreateExplorePermalinkCommand.run"
) as mock_permalink,
):
result = generate_explore_link(
123, {"viz_type": "table"}, prefer_permalink=False
)
assert (
result == "http://localhost:9001/explore/?form_data_key=test_form_data_key"
)
mock_command.assert_called_once()
mock_permalink.assert_not_called()
@patch("superset.mcp_service.chart.chart_utils.get_superset_base_url")
def test_generate_explore_link_exception_handling(self, mock_get_base_url) -> None:
"""Test generate_explore_link handles SQLAlchemy exceptions gracefully"""

View File

@@ -49,6 +49,13 @@ generate_explore_link_module = importlib.import_module(
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
_PERMALINK_PATCH = (
"superset.commands.explore.permalink.create.CreateExplorePermalinkCommand.run"
)
_FORM_DATA_PATCH = (
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.fixture
def mcp_server():
@@ -100,6 +107,17 @@ def mock_webdriver_baseurl(app_context):
current_app.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = original_value
@pytest.fixture(autouse=True)
def mock_permalink_creation():
"""Create durable permalink by default.
Override in individual tests that need fallback-to-form_data_key or
fallback-to-basic-URL behaviour by patching _PERMALINK_PATCH to raise.
"""
with patch(_PERMALINK_PATCH, return_value="test_permalink_key"):
yield
def _mock_dataset(id: int = 1) -> Mock:
"""Create a mock dataset object with columns and db_engine_spec."""
from superset.utils.core import ColumnSpec, GenericDataType
@@ -132,15 +150,11 @@ class TestGenerateExploreLink:
"""Comprehensive tests for generate_explore_link MCP tool."""
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_table_explore_link_minimal(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for minimal table chart."""
mock_create_form_data.return_value = "test_form_data_key_123"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -156,21 +170,18 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=test_form_data_key_123"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_table_explore_link_with_features(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for table chart with features."""
mock_create_form_data.return_value = "comprehensive_key_456"
mock_find_dataset.return_value = _mock_dataset(id=5)
config = TableChartConfig(
@@ -196,21 +207,18 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=comprehensive_key_456"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_ag_grid_table_explore_link_label(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
) -> None:
"""Test generating explore link reports AG Grid table label."""
mock_create_form_data.return_value = "ag_grid_key_123"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -229,15 +237,11 @@ class TestGenerateExploreLink:
assert result.data["chart_type_label"] == "interactive table chart"
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_line_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for line chart."""
mock_create_form_data.return_value = "line_chart_key_789"
mock_find_dataset.return_value = _mock_dataset(id=3)
config = XYChartConfig(
@@ -263,21 +267,14 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=line_chart_key_789"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["chart_type_label"] is None
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_bar_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
):
async def test_generate_bar_chart_explore_link(self, mock_find_dataset, mcp_server):
"""Test generating explore link for bar chart."""
mock_create_form_data.return_value = "bar_chart_key_abc"
mock_find_dataset.return_value = _mock_dataset(id=7)
config = XYChartConfig(
@@ -298,20 +295,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=bar_chart_key_abc"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_area_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for area chart."""
mock_create_form_data.return_value = "area_chart_key_def"
mock_find_dataset.return_value = _mock_dataset(id=2)
config = XYChartConfig(
@@ -335,20 +327,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=area_chart_key_def"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_scatter_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for scatter chart."""
mock_create_form_data.return_value = "scatter_chart_key_ghi"
mock_find_dataset.return_value = _mock_dataset(id=4)
config = XYChartConfig(
@@ -370,22 +357,71 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=scatter_chart_key_ghi"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_permalink_fails_fallback_to_form_data_key(
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""When permalink creation fails, fall back to ephemeral form_data_key URL."""
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"DB unavailable"
)
mock_create_form_data.return_value = "fallback_form_data_key"
config = TableChartConfig(
chart_type="table", columns=[ColumnRef(name="test_col")]
)
request = GenerateExploreLinkRequest(dataset_id="1", config=config)
async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=fallback_form_data_key"
)
assert result.data["form_data_key"] == "fallback_form_data_key"
assert result.data["permalink_key"] is None
mock_create_form_data.assert_called_once()
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_cache_failure_fallback(
self, mock_create_form_data, mock_find_dataset, mcp_server
async def test_generate_explore_link_both_fail_fallback_to_basic_url(
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback when form_data cache creation fails."""
mock_find_dataset.return_value = _mock_dataset(id=1)
"""When both permalink and form_data_key fail, fall back to basic URL."""
from superset.commands.exceptions import CommandException
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"DB unavailable"
)
mock_create_form_data.side_effect = CommandException("Cache storage failed")
config = TableChartConfig(
@@ -398,28 +434,31 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# Should fallback to basic URL format
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=1"
)
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_database_lock_fallback(
self, mock_create_form_data, mock_find_dataset, mcp_server
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback when database is locked."""
"""When permalink fails with SQLAlchemy error, fall back to form_data_key."""
from sqlalchemy.exc import OperationalError
mock_find_dataset.return_value = _mock_dataset(id=5)
mock_create_form_data.side_effect = OperationalError(
mock_create_permalink.side_effect = OperationalError(
"database is locked", None, None
)
mock_create_form_data.return_value = "lock_fallback_key"
config = XYChartConfig(
chart_type="xy",
@@ -434,23 +473,18 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# Should fallback to basic dataset URL
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=5"
== "http://localhost:9001/explore/?form_data_key=lock_fallback_key"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_with_many_columns(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link with many columns."""
mock_create_form_data.return_value = "many_columns_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
# Create 15 columns
@@ -474,20 +508,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=many_columns_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_with_many_filters(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link with many filters."""
mock_create_form_data.return_value = "many_filters_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
# Create 12 filters
@@ -517,20 +546,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=many_filters_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_explore_link_url_format_consistency(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test that all generated URLs follow consistent format."""
mock_create_form_data.return_value = "consistency_test_key"
"""Test that all generated URLs follow consistent permalink format."""
mock_find_dataset.return_value = _mock_dataset(id=1)
configs = [
@@ -569,23 +593,19 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# All URLs should follow the same format
# All URLs should follow the same permalink format
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=consistency_test_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["error"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_dataset_id_types(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test explore link generation with different dataset_id formats."""
mock_create_form_data.return_value = "dataset_test_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -604,19 +624,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=dataset_test_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_complex_configuration(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test explore link generation with complex chart configuration."""
mock_create_form_data.return_value = "complex_config_key"
mock_find_dataset.return_value = _mock_dataset(id=10)
config = XYChartConfig(
@@ -648,22 +664,30 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=complex_config_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_fallback_url_different_datasets(
self, mock_create_form_data, mock_find_dataset, mcp_server
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback URLs are correct for different dataset IDs."""
mock_find_dataset.return_value = _mock_dataset(id=1)
"""When both fallbacks fail, basic URL uses the correct dataset_id."""
from superset.commands.exceptions import CommandException
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"Always fail for fallback testing"
)
mock_create_form_data.side_effect = CommandException(
"Always fail for fallback testing"
)
@@ -680,7 +704,10 @@ class TestGenerateExploreLink:
)
# Should fallback to basic URL with correct dataset_id
expected_url = f"http://localhost:9001/explore/?datasource_type=table&datasource_id={dataset_id}"
expected_url = (
f"http://localhost:9001/explore/?datasource_type=table"
f"&datasource_id={dataset_id}"
)
assert result.data["error"] is None
assert result.data["url"] == expected_url
@@ -720,22 +747,21 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Invalid config structure" in result.data["error"]
finally:
# Restore original function
explore_module.map_config_to_form_data = original_func
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_returns_form_data_key(
self, mock_create_form_data, mock_find_dataset, mcp_server
async def test_generate_explore_link_returns_permalink_key(
self, mock_find_dataset, mock_create_permalink, mcp_server
):
"""Test that form_data_key is properly extracted from URL."""
mock_create_form_data.return_value = "extracted_form_key_xyz"
"""Test that permalink_key is properly extracted from the durable URL."""
mock_create_permalink.return_value = "extracted_permalink_xyz"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -749,19 +775,19 @@ class TestGenerateExploreLink:
)
assert result.data["error"] is None
assert result.data["form_data_key"] == "extracted_form_key_xyz"
assert "form_data_key=extracted_form_key_xyz" in result.data["url"]
assert result.data["permalink_key"] == "extracted_permalink_xyz"
assert result.data["form_data_key"] is None
assert "extracted_permalink_xyz" in result.data["url"]
assert result.data["url"] == (
"http://localhost:9001/explore/p/extracted_permalink_xyz/"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_returns_form_data(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test that form_data dict is returned for external rendering."""
mock_create_form_data.return_value = "form_data_test_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = XYChartConfig(
@@ -806,6 +832,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
assert "list_datasets" in result.data["error"]
@@ -833,6 +860,7 @@ class TestGenerateExploreLink:
)
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@@ -853,6 +881,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
@@ -879,6 +908,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found" in result.data["error"]
@@ -895,19 +925,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_xy_chart_x_axis_normalized_in_form_data(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""x-axis column name in wrong case is normalized in form_data."""
mock_create_form_data.return_value = "norm_test_key_1"
mock_find_dataset.return_value = _mock_dataset(id=18)
mock_get_context.return_value = DatasetContext(
id=18,
@@ -942,19 +967,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_filter_column_normalized_in_form_data(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""Filter column name in wrong case is normalized in adhoc_filters."""
mock_create_form_data.return_value = "norm_test_key_2"
mock_find_dataset.return_value = _mock_dataset(id=18)
mock_get_context.return_value = DatasetContext(
id=18,
@@ -1001,19 +1021,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_normalization_fallback_when_dataset_not_found(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""When dataset context is unavailable, original names pass through."""
mock_create_form_data.return_value = "norm_test_key_3"
mock_find_dataset.return_value = _mock_dataset(id=99)
mock_get_context.return_value = None
@@ -1046,21 +1061,19 @@ class TestGenerateExploreLinkValidation:
"""
return
@patch(_PERMALINK_PATCH)
@patch.object(generate_explore_link_module, "validate_and_compile")
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_validation_failure_returns_structured_error(
self,
mock_create_form_data,
mock_find_dataset,
mock_validate,
mock_create_permalink,
mcp_server,
):
"""Non-existent column → structured ChartGenerationError with suggestions,
and MCPCreateFormDataCommand must NOT be called (no cache write)."""
and CreateExplorePermalinkCommand must NOT be called (no cache write)."""
from superset.mcp_service.chart.compile import CompileResult
from superset.mcp_service.common.error_schemas import ChartGenerationError
@@ -1094,29 +1107,28 @@ class TestGenerateExploreLinkValidation:
assert result.data["url"] == ""
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
error = result.data["error"]
assert isinstance(error, dict)
assert error["error_code"] == "CHART_VALIDATION_FAILED"
assert "sum_boys" in error["suggestions"]
mock_create_form_data.assert_not_called()
mock_create_permalink.assert_not_called()
@patch(_PERMALINK_PATCH)
@patch.object(
generate_explore_link_module, "has_dataset_access", return_value=False
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_dataset_access_denied_short_circuits(
self,
mock_create_form_data,
mock_find_dataset,
unused_access_mock,
mock_create_permalink,
mcp_server,
):
"""has_dataset_access=False blocks the tool before any cache write."""
"""has_dataset_access=False blocks the tool before any permalink write."""
mock_find_dataset.return_value = _mock_dataset(id=3)
config = TableChartConfig(
@@ -1133,4 +1145,4 @@ class TestGenerateExploreLinkValidation:
assert result.data["chart_type_label"] is None
# Surface as "not found" rather than leaking that the dataset exists.
assert "Dataset not found" in result.data["error"]
mock_create_form_data.assert_not_called()
mock_create_permalink.assert_not_called()

View File

@@ -0,0 +1,46 @@
# 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.
from superset.mcp_service.utils.url_utils import extract_permalink_key_from_url
def test_extract_permalink_key_from_url_with_trailing_slash():
url = "http://localhost:8088/explore/p/abc123/"
assert extract_permalink_key_from_url(url) == "abc123"
def test_extract_permalink_key_from_url_without_trailing_slash():
url = "http://localhost:8088/explore/p/abc123"
assert extract_permalink_key_from_url(url) == "abc123"
def test_extract_permalink_key_from_url_no_match():
url = "http://localhost:8088/explore/?form_data_key=abc123"
assert extract_permalink_key_from_url(url) is None
def test_extract_permalink_key_from_url_none():
assert extract_permalink_key_from_url(None) is None
def test_extract_permalink_key_from_url_empty():
assert extract_permalink_key_from_url("") is None
def test_extract_permalink_key_from_url_with_path_prefix():
url = "https://example.com/superset/explore/p/xyz789/"
assert extract_permalink_key_from_url(url) == "xyz789"