mirror of
https://github.com/apache/superset.git
synced 2026-06-11 18:49:15 +00:00
Compare commits
7 Commits
enxdev/fea
...
mcp-chart-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ebc94b2f | ||
|
|
053f1292a4 | ||
|
|
8b85cdbd4a | ||
|
|
c8d6f09aa9 | ||
|
|
0f65ec35ca | ||
|
|
d694de8856 | ||
|
|
62fae8c493 |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1189
superset-frontend/package-lock.json
generated
1189
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,5 @@ export interface LabeledErrorBoundInputProps {
|
||||
id?: string;
|
||||
classname?: string;
|
||||
visibilityToggle?: boolean;
|
||||
isValidating?: boolean;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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'};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -127,8 +127,6 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
|
||||
accept={[]}
|
||||
ghostButtonText={ghostButtonText}
|
||||
onClickGhostButton={handleClickGhostButton}
|
||||
sortableType="ContourOption"
|
||||
itemCount={contours.length}
|
||||
{...props}
|
||||
/>
|
||||
<ContourPopoverTrigger
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
46
tests/unit_tests/mcp_service/test_url_utils.py
Normal file
46
tests/unit_tests/mcp_service/test_url_utils.py
Normal 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"
|
||||
Reference in New Issue
Block a user