feat(viz): add query mode switch to table chart (#10113)

1, Replace table chart rendering from jquery.DataTables to react-table: apache-superset/superset-ui#623
2. Rearrange the control panel, replace GROUP BY and NOT GROUP BY with a "Query Mode" switch: apache-superset/superset-ui#609
This commit is contained in:
Jesse Yang 2020-06-28 21:37:04 -07:00 committed by GitHub
parent 3414f35792
commit 9bdfa055ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 5549 additions and 9234 deletions

View File

@ -61,7 +61,7 @@ describe('Dashboard filter', () => {
expect(nodes).to.have.length(9);
});
cy.get('.Select__control input[type=text]').first().blur();
cy.get('.Select__control input[type=text]').first().focus().blur();
// should hide the filter indicator
cy.get('.filter-indicator')

File diff suppressed because it is too large Load Diff

View File

@ -61,14 +61,14 @@
"@emotion/core": "^10.0.28",
"@superset-ui/chart": "^0.14.1",
"@superset-ui/chart-composition": "^0.14.1",
"@superset-ui/color": "^0.14.1",
"@superset-ui/color": "^0.14.2",
"@superset-ui/connection": "^0.14.0",
"@superset-ui/chart-controls": "^0.14.1",
"@superset-ui/chart-controls": "^0.14.2",
"@superset-ui/core": "^0.14.0",
"@superset-ui/dimension": "^0.14.0",
"@superset-ui/legacy-plugin-chart-calendar": "^0.14.1",
"@superset-ui/legacy-plugin-chart-chord": "^0.14.1",
"@superset-ui/legacy-plugin-chart-country-map": "^0.14.1",
"@superset-ui/legacy-plugin-chart-country-map": "^0.14.2",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.14.1",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.14.1",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.14.1",
@ -85,7 +85,7 @@
"@superset-ui/legacy-plugin-chart-sankey": "^0.14.1",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.14.1",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.14.1",
"@superset-ui/legacy-plugin-chart-table": "^0.14.1",
"@superset-ui/plugin-chart-table": "^0.14.2",
"@superset-ui/legacy-plugin-chart-treemap": "^0.14.1",
"@superset-ui/legacy-plugin-chart-world-map": "^0.14.1",
"@superset-ui/legacy-preset-chart-big-number": "^0.14.1",
@ -96,7 +96,7 @@
"@superset-ui/preset-chart-xy": "^0.14.1",
"@superset-ui/query": "^0.14.1",
"@superset-ui/style": "^0.14.0",
"@superset-ui/superset-ui": "^0.14.1",
"@superset-ui/superset-ui": "^0.14.2",
"@superset-ui/time-format": "^0.14.1",
"@superset-ui/translation": "^0.14.0",
"@superset-ui/validator": "^0.14.1",
@ -110,6 +110,7 @@
"@types/redux-localstorage": "^1.0.8",
"@types/rison": "0.0.6",
"@vx/responsive": "^0.0.195",
"memoize-one": "^5.1.1",
"abortcontroller-polyfill": "^1.1.9",
"aphrodite": "^2.3.1",
"array-move": "^2.2.1",
@ -166,7 +167,7 @@
"react-split": "^2.0.4",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-table": "^7.0.4",
"react-table": "^7.2.1",
"react-transition-group": "^2.5.3",
"react-ultimate-pagination": "^1.2.0",
"react-virtualized": "9.19.1",
@ -178,7 +179,7 @@
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
"redux-undo": "^1.0.0-beta9-9-7",
"regenerator-runtime": "^0.13.3",
"regenerator-runtime": "^0.13.5",
"rison": "^0.1.1",
"shortid": "^2.2.6",
"urijs": "^1.18.10",
@ -202,19 +203,19 @@
"@svgr/webpack": "^5.4.0",
"@types/classnames": "^2.2.9",
"@types/dom-to-image": "^2.6.0",
"@types/jest": "^25.1.4",
"@types/jest": "^26.0.3",
"@types/jquery": "^3.3.32",
"@types/react": "^16.9.38",
"@types/react-dom": "^16.9.8",
"@types/react-json-tree": "^0.6.11",
"@types/react-redux": "^7.1.7",
"@types/react-table": "^7.0.2",
"@types/react-table": "^7.0.19",
"@types/react-ultimate-pagination": "^1.2.0",
"@types/yargs": "12 - 15",
"@typescript-eslint/eslint-plugin": "^2.20.0",
"@typescript-eslint/parser": "^2.20.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^25.1.0",
"babel-jest": "^26.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-dynamic-import-node": "^2.3.0",
"babel-plugin-emotion": "^10.0.29",
@ -233,7 +234,7 @@
"eslint-import-resolver-webpack": "^0.10.1",
"eslint-plugin-cypress": "^2.0.1",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jest": "^21.24.1",
"eslint-plugin-jest": "^23.17.1",
"eslint-plugin-jsx-a11y": "^5.1.1",
"eslint-plugin-no-only-tests": "^2.0.1",
"eslint-plugin-prettier": "^3.1.3",
@ -244,8 +245,8 @@
"fork-ts-checker-webpack-plugin": "^0.4.9",
"ignore-styles": "^5.0.1",
"imports-loader": "^0.7.1",
"jest": "^25.1.0",
"jsdom": "9.12.0",
"jest": "^26.1.0",
"jsdom": "^16.2.2",
"less": "^3.9.0",
"less-loader": "^5.0.0",
"mini-css-extract-plugin": "^0.4.0",
@ -261,7 +262,7 @@
"terser-webpack-plugin": "^1.1.0",
"thread-loader": "^1.2.0",
"transform-loader": "^0.2.3",
"ts-jest": "^25.4.0",
"ts-jest": "^26.1.1",
"ts-loader": "^6.2.1",
"typescript": "^3.8.3",
"url-loader": "^1.0.1",

View File

@ -20,7 +20,6 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import jsdom from 'jsdom';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import { configure as configureTranslation } from '@superset-ui/translation';
@ -31,11 +30,6 @@ configure({ adapter: new Adapter() });
const exposedProperties = ['window', 'navigator', 'document'];
global.jsdom = jsdom.jsdom;
global.document = global.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView;
global.HTMLElement = window.HTMLElement;
Object.keys(document.defaultView).forEach(property => {
if (typeof global[property] === 'undefined') {
exposedProperties.push(property);
@ -43,27 +37,6 @@ Object.keys(document.defaultView).forEach(property => {
}
});
global.navigator = {
userAgent: 'node.js',
platform: 'linux',
appName: 'Netscape',
};
// Fix `Option is not defined`
// https://stackoverflow.com/questions/39501589/jsdom-option-is-not-defined-when-running-my-mocha-test
global.Option = window.Option;
// Configuration copied from https://github.com/sinonjs/sinon/issues/657
// allowing for sinon.fakeServer to work
global.window = global.document.defaultView;
global.XMLHttpRequest = global.window.XMLHttpRequest;
global.sinon = require('sinon');
global.sinon.useFakeXMLHttpRequest();
global.window.XMLHttpRequest = global.XMLHttpRequest;
global.window.location = { href: 'about:blank' };
global.window.performance = { now: () => new Date().getTime() };
global.$ = require('jquery')(global.window);

View File

@ -62,53 +62,61 @@ describe('AsyncSelect', () => {
});
describe('auto select', () => {
it('should not call onChange if autoSelect=false', done => {
expect.assertions(2);
it('should not call onChange if autoSelect=false', () => {
return new Promise(done => {
expect.assertions(2);
const onChangeSpy = jest.fn();
shallow(<AsyncSelect {...mockedProps} onChange={onChangeSpy} />);
const onChangeSpy = jest.fn();
shallow(<AsyncSelect {...mockedProps} onChange={onChangeSpy} />);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
done();
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
done();
});
});
});
it('should auto select the first option if autoSelect=true', done => {
expect.assertions(3);
it('should auto select the first option if autoSelect=true', () => {
return new Promise(done => {
expect.assertions(3);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect {...mockedProps} onChange={onChangeSpy} autoSelect />,
);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect {...mockedProps} onChange={onChangeSpy} autoSelect />,
);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(1);
expect(onChangeSpy).toBeCalledWith(wrapper.instance().state.options[0]);
done();
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(1);
expect(onChangeSpy).toBeCalledWith(
wrapper.instance().state.options[0],
);
done();
});
});
});
it('should not auto select when value prop is set and autoSelect=true', done => {
expect.assertions(3);
it('should not auto select when value prop is set and autoSelect=true', () => {
return new Promise(done => {
expect.assertions(3);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect
{...mockedProps}
value={2}
onChange={onChangeSpy}
autoSelect
/>,
);
const onChangeSpy = jest.fn();
const wrapper = shallow(
<AsyncSelect
{...mockedProps}
value={2}
onChange={onChangeSpy}
autoSelect
/>,
);
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
expect(wrapper.find(Select)).toHaveLength(1);
done();
setTimeout(() => {
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
expect(onChangeSpy.mock.calls).toHaveLength(0);
expect(wrapper.find(Select)).toHaveLength(1);
done();
});
});
});

View File

@ -30,7 +30,7 @@ import Pagination from 'src/components/Pagination';
import { areArraysShallowEqual } from 'src/reduxUtils';
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
export function makeMockLocation(query) {
function makeMockLocation(query) {
const queryStr = encodeURIComponent(query);
return {
protocol: 'http:',
@ -292,16 +292,14 @@ Array [
...mockedProps,
filters: [...mockedProps.filters, { id: 'some_column' }],
};
try {
expect(() => {
shallow(<ListView {...props} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ListViewError: Invalid filter config, some_column is not present in columns]`,
);
}
}).toThrowErrorMatchingInlineSnapshot(
'"Invalid filter config, some_column is not present in columns"',
);
});
});

View File

@ -176,7 +176,7 @@ describe('TableSelector', () => {
});
// Test needs to be fixed: Github issue #7768
xit('should dispatch a danger toast on error', () => {
it.skip('should dispatch a danger toast on error', () => {
fetchMock.get(
FETCH_TABLES_GLOB,
{ throws: 'error' },
@ -218,7 +218,7 @@ describe('TableSelector', () => {
});
// Test needs to be fixed: Github issue #7768
xit('should dispatch a danger toast on error', () => {
it.skip('should dispatch a danger toast on error', () => {
const handleErrors = sinon.stub();
expect(handleErrors.callCount).toBe(0);
wrapper.setProps({ handleErrors });

View File

@ -70,25 +70,29 @@ describe('ChangeDatasourceModal', () => {
expect(wrapper.find(Modal)).toHaveLength(1);
});
it('fetches datasources', done => {
inst.onEnterModal();
setTimeout(() => {
expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1);
fetchMock.reset();
done();
}, 0);
it('fetches datasources', () => {
return new Promise(done => {
inst.onEnterModal();
setTimeout(() => {
expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1);
fetchMock.reset();
done();
}, 0);
});
});
it('changes the datasource', done => {
fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
inst.selectDatasource(datasourceData);
setTimeout(() => {
expect(fetchMock.calls(DATASOURCE_ENDPOINT)).toHaveLength(1);
expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(
DATASOURCE_PAYLOAD,
);
fetchMock.reset();
done();
}, 0);
it('changes the datasource', () => {
return new Promise(done => {
fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
inst.selectDatasource(datasourceData);
setTimeout(() => {
expect(fetchMock.calls(DATASOURCE_ENDPOINT)).toHaveLength(1);
expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(
DATASOURCE_PAYLOAD,
);
fetchMock.reset();
done();
}, 0);
});
});
});

View File

@ -70,17 +70,19 @@ describe('DatasourceEditor', () => {
expect(wrapper.find(Tabs)).toHaveLength(1);
});
it('makes an async request', done => {
wrapper.setState({ activeTabKey: 2 });
const syncButton = wrapper.find('.sync-from-source');
expect(syncButton).toHaveLength(1);
syncButton.simulate('click');
it('makes an async request', () => {
return new Promise(done => {
wrapper.setState({ activeTabKey: 2 });
const syncButton = wrapper.find('.sync-from-source');
expect(syncButton).toHaveLength(1);
syncButton.simulate('click');
setTimeout(() => {
expect(fetchMock.calls(DATASOURCE_ENDPOINT)).toHaveLength(1);
fetchMock.reset();
done();
}, 0);
setTimeout(() => {
expect(fetchMock.calls(DATASOURCE_ENDPOINT)).toHaveLength(1);
fetchMock.reset();
done();
}, 0);
});
});
it('merges columns', () => {

View File

@ -68,13 +68,15 @@ describe('DatasourceModal', () => {
expect(wrapper.find(DatasourceEditor)).toHaveLength(1);
});
it('saves on confirm', done => {
inst.onConfirmSave();
setTimeout(() => {
expect(fetchMock.calls(SAVE_ENDPOINT)).toHaveLength(1);
expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(SAVE_PAYLOAD);
fetchMock.reset();
done();
}, 0);
it('saves on confirm', () => {
return new Promise(done => {
inst.onConfirmSave();
setTimeout(() => {
expect(fetchMock.calls(SAVE_ENDPOINT)).toHaveLength(1);
expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(SAVE_PAYLOAD);
fetchMock.reset();
done();
}, 0);
});
});
});

View File

@ -181,36 +181,40 @@ describe('MetricsControl', () => {
]);
});
it('handles aggregates being selected', done => {
const { wrapper, onChange } = setup();
const select = wrapper.find(OnPasteSelect);
it('handles aggregates being selected', () => {
return new Promise(done => {
const { wrapper, onChange } = setup();
const select = wrapper.find(OnPasteSelect);
// mock out the Select ref
const instance = wrapper.instance();
const handleInputChangeSpy = jest.fn();
const focusInputSpy = jest.fn();
// simulate react-select StateManager
instance.selectRef({
select: {
handleInputChange: handleInputChangeSpy,
inputRef: { value: '' },
focusInput: focusInputSpy,
},
});
// mock out the Select ref
const instance = wrapper.instance();
const handleInputChangeSpy = jest.fn();
const focusInputSpy = jest.fn();
// simulate react-select StateManager
instance.selectRef({
select: {
handleInputChange: handleInputChangeSpy,
inputRef: { value: '' },
focusInput: focusInputSpy,
},
});
select.simulate('change', [{ aggregate_name: 'SUM', optionName: 'SUM' }]);
select.simulate('change', [
{ aggregate_name: 'SUM', optionName: 'SUM' },
]);
expect(instance.select.inputRef.value).toBe('SUM()');
expect(handleInputChangeSpy).toHaveBeenCalledWith({
currentTarget: { value: 'SUM()' },
});
expect(onChange.calledOnceWith([])).toBe(true);
expect(focusInputSpy).toHaveBeenCalledTimes(0);
setTimeout(() => {
expect(focusInputSpy).toHaveBeenCalledTimes(1);
expect(instance.select.inputRef.selectionStart).toBe(4);
expect(instance.select.inputRef.selectionEnd).toBe(4);
done();
expect(instance.select.inputRef.value).toBe('SUM()');
expect(handleInputChangeSpy).toHaveBeenCalledWith({
currentTarget: { value: 'SUM()' },
});
expect(onChange.calledOnceWith([])).toBe(true);
expect(focusInputSpy).toHaveBeenCalledTimes(0);
setTimeout(() => {
expect(focusInputSpy).toHaveBeenCalledTimes(1);
expect(instance.select.inputRef.selectionStart).toBe(4);
expect(instance.select.inputRef.selectionEnd).toBe(4);
done();
});
});
});

View File

@ -210,38 +210,47 @@ describe('SaveModal', () => {
Object.defineProperty(window, 'location', windowLocation);
});
it('Save & go to dashboard', done => {
wrapper.instance().saveOrOverwrite(true);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'http://localhost/mock_dashboard/',
);
done();
it('Save & go to dashboard', () => {
return new Promise(done => {
wrapper.instance().saveOrOverwrite(true);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'http://localhost/mock_dashboard/',
);
done();
});
});
});
it('saveas new slice', done => {
wrapper.setState({ action: 'saveas', newSliceName: 'new slice name' });
wrapper.instance().saveOrOverwrite(false);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'/mock_slice/',
);
done();
it('saveas new slice', () => {
return new Promise(done => {
wrapper.setState({
action: 'saveas',
newSliceName: 'new slice name',
});
wrapper.instance().saveOrOverwrite(false);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'/mock_slice/',
);
done();
});
});
});
it('overwrite original slice', done => {
wrapper.setState({ action: 'overwrite' });
wrapper.instance().saveOrOverwrite(false);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'/mock_slice/',
);
done();
it('overwrite original slice', () => {
return new Promise(done => {
wrapper.setState({ action: 'overwrite' });
wrapper.instance().saveOrOverwrite(false);
defaultProps.actions.saveSlice().then(() => {
expect(window.location.assign.callCount).toEqual(1);
expect(window.location.assign.getCall(0).args[0]).toEqual(
'/mock_slice/',
);
done();
});
});
});
});

View File

@ -105,6 +105,8 @@ describe('controlUtils', () => {
expanded: true,
controlSetRows: [
[
'metric',
'metrics',
{
name: 'all_columns',
config: {
@ -213,7 +215,12 @@ describe('controlUtils', () => {
expect(control.value).toBe('stack');
control = getControlState('stacked_style', 'test-chart', state, 'FOO');
expect(control.value).toBe(null);
expect(control.value).toBeNull();
});
it('returns null for non-existent field', () => {
const control = getControlState('NON_EXISTENT', 'table', state);
expect(control).toBeNull();
});
it('applies the default function for metrics', () => {
@ -254,7 +261,11 @@ describe('controlUtils', () => {
it('in formData', () => {
const controlsState = getAllControlsState('table', 'table', {}, {});
const formData = getFormDataFromControls(controlsState);
expect(formData.queryFields).toEqual({ all_columns: 'columns' });
expect(formData.queryFields).toEqual({
all_columns: 'columns',
metric: 'metrics',
metrics: 'metrics',
});
});
});
});

View File

@ -22,13 +22,13 @@ import exploreReducer from 'src/explore/reducers/exploreReducer';
import * as actions from 'src/explore/actions/exploreActions';
describe('reducers', () => {
it('sets correct control value given a key and value', () => {
it('sets correct control value given an arbitrary key and value', () => {
const newState = exploreReducer(
defaultState,
actions.setControlValue('x_axis_label', 'x', []),
actions.setControlValue('NEW_FIELD', 'x', []),
);
expect(newState.controls.x_axis_label.value).toBe('x');
expect(newState.form_data.x_axis_label).toBe('x');
expect(newState.controls.NEW_FIELD.value).toBe('x');
expect(newState.form_data.NEW_FIELD).toBe('x');
});
it('setControlValue works as expected with a checkbox', () => {
const newState = exploreReducer(

View File

@ -46,14 +46,16 @@ describe('Toast', () => {
expect(alert.childAt(0).childAt(1).text()).toBe(props.toast.text);
});
it('should call onCloseToast upon alert dismissal', done => {
const onCloseToast = id => {
expect(id).toBe(props.toast.id);
done();
};
const wrapper = setup({ onCloseToast });
const handleClosePress = wrapper.instance().handleClosePress;
expect(wrapper.find(Alert).prop('onDismiss')).toBe(handleClosePress);
handleClosePress(); // there is a timeout for onCloseToast to be called
it('should call onCloseToast upon alert dismissal', () => {
return new Promise(done => {
const onCloseToast = id => {
expect(id).toBe(props.toast.id);
done();
};
const wrapper = setup({ onCloseToast });
const handleClosePress = wrapper.instance().handleClosePress;
expect(wrapper.find(Alert).prop('onDismiss')).toBe(handleClosePress);
handleClosePress(); // there is a timeout for onCloseToast to be called
});
});
});

View File

@ -76,7 +76,6 @@ describe('EditableTitle', () => {
describe('should handle blur', () => {
beforeEach(() => {
editableWrapper.find('input').simulate('click');
expect(editableWrapper.find('input').props().type).toBe('text');
});
afterEach(() => {
callback.resetHistory();
@ -84,6 +83,10 @@ describe('EditableTitle', () => {
editableWrapper.setState({ lastTitle: 'my title' });
});
it('default input type should be text', () => {
expect(editableWrapper.find('input').props().type).toBe('text');
});
it('should trigger callback', () => {
editableWrapper.setState({ title: 'new title' });
editableWrapper.find('input').simulate('blur');

View File

@ -193,70 +193,76 @@ describe('ExploreResultsButton', () => {
fetchMock.reset();
});
it('should build request with correct args', done => {
wrapper.instance().visualize();
it('should build request with correct args', () => {
return new Promise(done => {
wrapper.instance().visualize();
setTimeout(() => {
const calls = fetchMock.calls(visualizeEndpoint);
expect(calls).toHaveLength(1);
const formData = calls[0][1].body;
setTimeout(() => {
const calls = fetchMock.calls(visualizeEndpoint);
expect(calls).toHaveLength(1);
const formData = calls[0][1].body;
Object.keys(mockOptions).forEach(key => {
// eslint-disable-next-line no-unused-expressions
expect(formData.get(key)).toBeDefined();
Object.keys(mockOptions).forEach(key => {
// eslint-disable-next-line no-unused-expressions
expect(formData.get(key)).toBeDefined();
});
done();
});
});
});
it('should export chart and add an info toast', () => {
return new Promise(done => {
const infoToastSpy = sinon.spy();
const datasourceSpy = sinon.stub();
datasourceSpy.callsFake(() => Promise.resolve(visualizationPayload));
wrapper.setProps({
actions: {
addInfoToast: infoToastSpy,
createDatasource: datasourceSpy,
},
});
done();
wrapper.instance().visualize();
setTimeout(() => {
expect(datasourceSpy.callCount).toBe(1);
expect(exploreUtils.exploreChart.callCount).toBe(1);
expect(exploreUtils.exploreChart.getCall(0).args[0].datasource).toBe(
'107__table',
);
expect(infoToastSpy.callCount).toBe(1);
done();
});
});
});
it('should export chart and add an info toast', done => {
const infoToastSpy = sinon.spy();
const datasourceSpy = sinon.stub();
it('should add error toast', () => {
return new Promise(done => {
const dangerToastSpy = sinon.stub(actions, 'addDangerToast');
const datasourceSpy = sinon.stub();
datasourceSpy.callsFake(() => Promise.resolve(visualizationPayload));
datasourceSpy.callsFake(() => Promise.reject({ error: 'error' }));
wrapper.setProps({
actions: {
addInfoToast: infoToastSpy,
createDatasource: datasourceSpy,
},
});
wrapper.setProps({
actions: {
createDatasource: datasourceSpy,
addDangerToast: dangerToastSpy,
},
});
wrapper.instance().visualize();
wrapper.instance().visualize();
setTimeout(() => {
expect(datasourceSpy.callCount).toBe(1);
expect(exploreUtils.exploreChart.callCount).toBe(1);
expect(exploreUtils.exploreChart.getCall(0).args[0].datasource).toBe(
'107__table',
);
expect(infoToastSpy.callCount).toBe(1);
done();
});
});
it('should add error toast', done => {
const dangerToastSpy = sinon.stub(actions, 'addDangerToast');
const datasourceSpy = sinon.stub();
datasourceSpy.callsFake(() => Promise.reject({ error: 'error' }));
wrapper.setProps({
actions: {
createDatasource: datasourceSpy,
addDangerToast: dangerToastSpy,
},
});
wrapper.instance().visualize();
setTimeout(() => {
expect(datasourceSpy.callCount).toBe(1);
expect(exploreUtils.exportChart.callCount).toBe(0);
expect(dangerToastSpy.callCount).toBe(1);
dangerToastSpy.restore();
done();
setTimeout(() => {
expect(datasourceSpy.callCount).toBe(1);
expect(exploreUtils.exportChart.callCount).toBe(0);
expect(dangerToastSpy.callCount).toBe(1);
dangerToastSpy.restore();
done();
});
});
});
});

View File

@ -107,7 +107,7 @@ describe('async actions', () => {
});
});
xit('parses large number result without losing precision', () =>
it.skip('parses large number result without losing precision', () =>
makeRequest().then(() => {
expect(fetchMock.calls(fetchQueryEndpoint)).toHaveLength(1);
expect(dispatch.callCount).toBe(2);
@ -175,7 +175,7 @@ describe('async actions', () => {
});
});
xit('parses large number result without losing precision', () =>
it.skip('parses large number result without losing precision', () =>
makeRequest().then(() => {
expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1);
expect(dispatch.callCount).toBe(2);

View File

@ -47,16 +47,18 @@ function setup() {
describe('DashboardTable', () => {
beforeEach(fetchMock.resetHistory);
it('fetches dashboards and renders a ListView', done => {
const wrapper = setup();
it('fetches dashboards and renders a ListView', () => {
return new Promise(done => {
const wrapper = setup();
setTimeout(() => {
expect(fetchMock.calls(dashboardsEndpoint)).toHaveLength(1);
// there's a delay between response and updating state, so manually set it
// rather than adding a timeout which could introduce flakiness
wrapper.setState({ dashaboards: mockDashboards });
expect(wrapper.find(ListView)).toHaveLength(1);
done();
setTimeout(() => {
expect(fetchMock.calls(dashboardsEndpoint)).toHaveLength(1);
// there's a delay between response and updating state, so manually set it
// rather than adding a timeout which could introduce flakiness
wrapper.setState({ dashaboards: mockDashboards });
expect(wrapper.find(ListView)).toHaveLength(1);
done();
});
});
});
});

View File

@ -196,12 +196,22 @@ class ChartRenderer extends React.Component {
? `superset-chart-${snakeCaseVizType}`
: snakeCaseVizType;
const webpackHash =
process.env.WEBPACK_MODE === 'development'
? `-${
// eslint-disable-next-line camelcase
typeof __webpack_require__ !== 'undefined' &&
// eslint-disable-next-line camelcase, no-undef
typeof __webpack_require__.h === 'function' &&
// eslint-disable-next-line no-undef
__webpack_require__.h()
}`
: '';
return (
<SuperChart
disableErrorBoundary
key={`${chartId}${
process.env.WEBPACK_MODE === 'development' ? `-${Date.now()}` : ''
}`}
key={`${chartId}${webpackHash}`}
id={`chart-id-${chartId}`}
className={chartClassName}
chartType={vizType}

View File

@ -103,7 +103,7 @@ export default function TableCollection({
return column.hidden ? null : (
<th
{...column.getHeaderProps(
column.sortable ? column.getSortByToggleProps() : {},
column.canSort ? column.getSortByToggleProps() : {},
)}
data-test="sort-header"
className={cx({
@ -112,7 +112,7 @@ export default function TableCollection({
>
<span>
{column.render('Header')}
{column.sortable && sortIcon}
{column.canSort && sortIcon}
</span>
</th>
);

View File

@ -27,7 +27,10 @@ const controlTypes = Object.keys(controlMap);
const propTypes = {
actions: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.oneOf(controlTypes).isRequired,
type: PropTypes.oneOfType([
PropTypes.oneOf(controlTypes).isRequired,
PropTypes.func.isRequired,
]),
hidden: PropTypes.bool,
label: PropTypes.string.isRequired,
choices: PropTypes.oneOfType([
@ -62,6 +65,8 @@ export default class Control extends React.PureComponent {
super(props);
this.state = { hovered: false };
this.onChange = this.onChange.bind(this);
this.onMouseEnter = this.setHover.bind(this, true);
this.onMouseLeave = this.setHover.bind(this, false);
}
onChange(value, errors) {
this.props.actions.setControlValue(this.props.name, value, errors);
@ -70,18 +75,18 @@ export default class Control extends React.PureComponent {
this.setState({ hovered });
}
render() {
if (!this.props.type) return null; // this catches things like <hr/> elements (not a control!) shoved into the control panel configs.
const ControlType = controlMap[this.props.type];
const divStyle = this.props.hidden ? { display: 'none' } : null;
const { type, hidden } = this.props;
if (!type) return null;
const ControlComponent = typeof type === 'string' ? controlMap[type] : type;
return (
<div
className="Control"
data-test={this.props.name}
style={divStyle}
onMouseEnter={this.setHover.bind(this, true)}
onMouseLeave={this.setHover.bind(this, false)}
style={hidden ? { display: 'none' } : undefined}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<ControlType
<ControlComponent
onChange={this.onChange}
hovered={this.state.hovered}
{...this.props}

View File

@ -25,11 +25,11 @@ import { Alert, Tab, Tabs } from 'react-bootstrap';
import { isPlainObject } from 'lodash';
import { t } from '@superset-ui/translation';
import { getChartControlPanelRegistry } from '@superset-ui/chart';
import { sharedControls } from '@superset-ui/chart-controls';
import ControlPanelSection from './ControlPanelSection';
import ControlRow from './ControlRow';
import Control from './Control';
import controlConfigs from '../controls';
import { sectionsToRender } from '../controlUtils';
import * as exploreActions from '../actions/exploreActions';
@ -47,42 +47,11 @@ class ControlPanelsContainer extends React.Component {
constructor(props) {
super(props);
this.getControlData = this.getControlData.bind(this);
this.removeAlert = this.removeAlert.bind(this);
this.renderControl = this.renderControl.bind(this);
this.renderControlPanelSection = this.renderControlPanelSection.bind(this);
}
getControlData(controlName) {
if (React.isValidElement(controlName)) {
return controlName;
}
const control = this.props.controls[controlName];
// Identifying mapStateToProps function to apply (logic can't be in store)
let mapF = controlConfigs[controlName].mapStateToProps;
// Looking to find mapStateToProps override for this viz type
const controlPanelConfig =
getChartControlPanelRegistry().get(this.props.controls.viz_type.value) ||
{};
const controlOverrides = controlPanelConfig.controlOverrides || {};
if (
controlOverrides[controlName] &&
controlOverrides[controlName].mapStateToProps
) {
mapF = controlOverrides[controlName].mapStateToProps;
}
// Applying mapStateToProps if needed
if (mapF) {
return {
...control,
...mapF(this.props.exploreState, control, this.props.actions),
};
}
return control;
}
sectionsToRender() {
return sectionsToRender(
this.props.form_data.viz_type,
@ -94,48 +63,45 @@ class ControlPanelsContainer extends React.Component {
this.props.actions.removeControlPanelAlert();
}
renderControl(name, config, lookupControlData) {
renderControl({ name, config }) {
const { actions, controls, exploreState, form_data: formData } = this.props;
const { visibility } = config;
// if visibility check says the config is not visible, don't render it
if (visibility && !visibility.call(config, this.props)) {
return null;
}
// Looking to find mapStateToProps override for this viz type
const controlPanelConfig =
getChartControlPanelRegistry().get(controls.viz_type.value) || {};
const controlOverrides = controlPanelConfig.controlOverrides || {};
const overrides = controlOverrides[name];
// Identifying mapStateToProps function to apply (logic can't be in store)
const mapFn =
overrides && overrides.mapStateToProps
? overrides.mapStateToProps
: config.mapStateToProps;
// If the control item is not an object, we have to look up the control data from
// the centralized controls file.
// When it is an object we read control data straight from `config` instead
const controlData = lookupControlData ? controls[name] : config;
const controlData = {
...controls[name],
...config,
name,
// apply current value in formData
value: formData[name],
};
const { mapStateToProps: mapFn } = controlData;
if (mapFn) {
Object.assign(
controlData,
mapFn(exploreState, controlData, actions) || {},
);
}
const {
validationErrors,
provideFormDataToProps,
...restProps
} = controlData;
// Applying mapStateToProps if needed
const additionalProps = mapFn
? { ...controlData, ...mapFn(exploreState, controlData, actions) }
: controlData;
const { validationErrors, provideFormDataToProps } = controlData;
// if visibility check says the config is not visible, don't render it
if (visibility && !visibility.call(config, this.props, controlData)) {
return null;
}
return (
<Control
name={name}
key={`control-${name}`}
value={formData[name]}
validationErrors={validationErrors}
actions={actions}
formData={provideFormDataToProps ? formData : null}
{...additionalProps}
{...restProps}
/>
);
}
@ -160,54 +126,51 @@ class ControlPanelsContainer extends React.Component {
hasErrors={hasErrors}
description={section.description}
>
{section.controlSetRows.map((controlSets, i) => (
<ControlRow
key={`controlsetrow-${i}`}
className="control-row"
controls={controlSets.map(controlItem => {
{section.controlSetRows.map((controlSets, i) => {
const renderedControls = controlSets
.map(controlItem => {
if (!controlItem) {
// When the item is invalid
return null;
} else if (React.isValidElement(controlItem)) {
// When the item is a React element
return controlItem;
} else if (
isPlainObject(controlItem) &&
controlItem.name &&
controlItem.config
) {
const { name, config } = controlItem;
return this.renderControl(name, config, false);
} else if (controls[controlItem]) {
// When the item is string name, meaning the control config
// is not specified directly. Have to look up the config from
// centralized configs.
const name = controlItem;
return this.renderControl(name, controlConfigs[name], true);
} else if (controlItem.name && controlItem.config) {
return this.renderControl(controlItem);
}
return null;
})}
/>
))}
})
.filter(x => x !== null);
// don't show the row if it is empty
if (renderedControls.length === 0) {
return null;
}
return (
<ControlRow
key={`controlsetrow-${i}`}
className="control-row"
controls={renderedControls}
/>
);
})}
</ControlPanelSection>
);
}
render() {
const allSectionsToRender = this.sectionsToRender();
const querySectionsToRender = [];
const displaySectionsToRender = [];
allSectionsToRender.forEach(section => {
// if at least one control in the secion is not `renderTrigger`
this.sectionsToRender().forEach(section => {
// if at least one control in the section is not `renderTrigger`
// or asks to be displayed at the Data tab
if (
section.tabOverride === 'data' ||
section.controlSetRows.some(rows =>
rows.some(
control =>
controlConfigs[control] &&
(!controlConfigs[control].renderTrigger ||
controlConfigs[control].tabOverride === 'data'),
control &&
control.config &&
(!control.config.renderTrigger ||
control.config.tabOverride === 'data'),
),
)
) {

View File

@ -243,6 +243,7 @@ export const NVD3TimeSeries = [
},
],
[<h1 className="section-header">{t('Python Functions')}</h1>],
// eslint-disable-next-line jsx-a11y/heading-has-content
[<h2 className="section-header">pandas.resample</h2>],
[
{

View File

@ -16,7 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import memoizeOne from 'memoize-one';
import { getChartControlPanelRegistry } from '@superset-ui/chart';
import { expandControlConfig } from '@superset-ui/chart-controls';
import { controls as SHARED_CONTROLS } from './controls';
import * as SECTIONS from './controlPanels/sections';
@ -32,13 +34,13 @@ export function getFormDataFromControls(controlsState) {
return formData;
}
export function validateControl(control) {
export function validateControl(control, processedState) {
const validators = control.validators;
if (validators && validators.length > 0) {
const validatedControl = { ...control };
const validationErrors = [];
validators.forEach(f => {
const v = f(control.value);
const v = f.call(control, control.value, processedState);
if (v) {
validationErrors.push(v);
}
@ -49,15 +51,20 @@ export function validateControl(control) {
return control;
}
function findCustomControl(controlPanelSections, controlKey) {
// find custom control in `controlPanelSections` and apply `controlOverrides` if needed.
/**
* Find control item from control panel config.
*/
function findControlItem(controlPanelSections, controlKey) {
for (const section of controlPanelSections) {
for (const controlArr of section.controlSetRows) {
for (const control of controlArr) {
if (control != null && typeof control === 'object') {
if (control.config && control.name === controlKey) {
return control.config;
}
if (controlKey === control) return control;
if (
control !== null &&
typeof control === 'object' &&
control.name === controlKey
) {
return control;
}
}
}
@ -65,23 +72,22 @@ function findCustomControl(controlPanelSections, controlKey) {
return null;
}
export function getControlConfig(controlKey, vizType) {
export const getControlConfig = memoizeOne(function getControlConfig(
controlKey,
vizType,
) {
const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
const {
controlOverrides = {},
controlPanelSections = [],
} = controlPanelConfig;
const config =
controlKey in SHARED_CONTROLS
? SHARED_CONTROLS[controlKey]
: findCustomControl(controlPanelSections, controlKey);
return {
...config,
...controlOverrides[controlKey],
};
}
const control = expandControlConfig(
findControlItem(controlPanelSections, controlKey),
controlOverrides,
);
return control?.config || control;
});
export function applyMapStateToPropsToControl(control, state) {
if (control.mapStateToProps) {
@ -118,6 +124,10 @@ function handleMissingChoice(control) {
}
export function getControlStateFromControlConfig(controlConfig, state, value) {
// skip invalid config values
if (!controlConfig) {
return null;
}
const controlState = applyMapStateToPropsToControl(
{ ...controlConfig },
state,
@ -134,7 +144,7 @@ export function getControlStateFromControlConfig(controlConfig, state, value) {
controlState.value =
typeof controlValue === 'undefined' ? controlState.default : controlValue;
return validateControl(handleMissingChoice(controlState));
return validateControl(handleMissingChoice(controlState), controlState);
}
export function getControlState(controlKey, vizType, state, value) {
@ -145,15 +155,24 @@ export function getControlState(controlKey, vizType, state, value) {
);
}
export function sectionsToRender(vizType, datasourceType) {
/**
* Get the clean and processed control panel sections
*/
export const sectionsToRender = memoizeOne(function sectionsToRender(
vizType,
datasourceType,
) {
const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
const {
sectionOverrides = {},
controlOverrides,
controlPanelSections = [],
} = controlPanelConfig;
// default control panel sections
const sections = { ...SECTIONS };
// apply section overrides
Object.entries(sectionOverrides).forEach(([section, overrides]) => {
if (typeof overrides === 'object' && overrides.constructor === Object) {
sections[section] = {
@ -171,34 +190,34 @@ export function sectionsToRender(vizType, datasourceType) {
return []
.concat(datasourceAndVizType, timeSection, controlPanelSections)
.filter(section => section);
}
.filter(section => !!section)
.map(section => {
const { controlSetRows } = section;
return {
...section,
controlSetRows:
controlSetRows?.map(row =>
row.map(item => expandControlConfig(item, controlOverrides)),
) || [],
};
});
});
export function getAllControlsState(vizType, datasourceType, state, formData) {
const controlsState = {};
sectionsToRender(vizType, datasourceType).forEach(section =>
section.controlSetRows.forEach(fieldsetRow =>
fieldsetRow.forEach(field => {
if (typeof field === 'string') {
controlsState[field] = getControlState(
field,
vizType,
if (field && field.config && field.name) {
const { config, name } = field;
controlsState[name] = getControlStateFromControlConfig(
config,
state,
formData[field],
formData[name],
);
} else if (field != null && typeof field === 'object') {
if (field.config && field.name) {
const { config, name } = field;
controlsState[name] = getControlStateFromControlConfig(
config,
state,
formData[name],
);
}
}
}),
),
);
return controlsState;
}

View File

@ -18,7 +18,11 @@
*/
/* eslint camelcase: 0 */
import { getControlsState } from '../store';
import { getControlState, getFormDataFromControls } from '../controlUtils';
import {
getControlConfig,
getFormDataFromControls,
getControlStateFromControlConfig,
} from '../controlUtils';
import * as actions from '../actions/exploreActions';
export default function exploreReducer(state = {}, action) {
@ -100,8 +104,14 @@ export default function exploreReducer(state = {}, action) {
// These errors are reported from the Control components
let errors = action.validationErrors || [];
const vizType = new_form_data.viz_type;
// Use the processed control config (with overrides and everything)
// if `controlName` does not existing in current controls,
const controlConfig =
state.controls[action.controlName] ||
getControlConfig(action.controlName, vizType) ||
{};
const control = {
...getControlState(action.controlName, vizType, state, action.value),
...getControlStateFromControlConfig(controlConfig, state, action.value),
};
// These errors are based on control config `validators`

View File

@ -118,7 +118,6 @@ declare module 'react-table' {
UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {
hidden?: boolean;
sortable?: boolean;
cellProps?: any;
size?: ColumnSize;
}
@ -129,7 +128,6 @@ declare module 'react-table' {
UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {
hidden?: boolean;
sortable?: boolean;
cellProps?: any;
size?: ColumnSize;
}

View File

@ -119,7 +119,7 @@ class ChartList extends React.PureComponent<Props, State> {
}: any) => <a href={url}>{sliceName}</a>,
Header: t('Chart'),
accessor: 'slice_name',
sortable: true,
canSort: true,
},
{
Cell: ({
@ -129,7 +129,7 @@ class ChartList extends React.PureComponent<Props, State> {
}: any) => vizType,
Header: t('Visualization Type'),
accessor: 'viz_type',
sortable: true,
canSort: true,
},
{
Cell: ({
@ -139,7 +139,7 @@ class ChartList extends React.PureComponent<Props, State> {
}: any) => <a href={dsUrl}>{dsNameTxt}</a>,
Header: t('Datasource'),
accessor: 'datasource_name',
sortable: true,
canSort: true,
},
{
Cell: ({
@ -152,7 +152,7 @@ class ChartList extends React.PureComponent<Props, State> {
}: any) => <a href={changedByUrl}>{changedByName}</a>,
Header: t('Creator'),
accessor: 'changed_by_fk',
sortable: true,
canSort: true,
},
{
Cell: ({
@ -162,7 +162,7 @@ class ChartList extends React.PureComponent<Props, State> {
}: any) => <span className="no-wrap">{moment(changedOn).fromNow()}</span>,
Header: t('Last Modified'),
accessor: 'changed_on',
sortable: true,
canSort: true,
},
{
accessor: 'description',

View File

@ -39,7 +39,7 @@ import PivotTableChartPlugin from '@superset-ui/legacy-plugin-chart-pivot-table'
import RoseChartPlugin from '@superset-ui/legacy-plugin-chart-rose';
import SankeyChartPlugin from '@superset-ui/legacy-plugin-chart-sankey';
import SunburstChartPlugin from '@superset-ui/legacy-plugin-chart-sunburst';
import TableChartPlugin from '@superset-ui/legacy-plugin-chart-table';
import TableChartPlugin from '@superset-ui/plugin-chart-table';
import TreemapChartPlugin from '@superset-ui/legacy-plugin-chart-treemap';
import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud';
import WorldMapChartPlugin from '@superset-ui/legacy-plugin-chart-world-map';

View File

@ -54,7 +54,7 @@ class DashboardTable extends React.PureComponent {
{
accessor: 'dashboard_title',
Header: 'Dashboard',
sortable: true,
canSort: true,
Cell: ({
row: {
original: { url, dashboard_title: dashboardTitle },
@ -64,7 +64,7 @@ class DashboardTable extends React.PureComponent {
{
accessor: 'changed_by_fk',
Header: 'Creator',
sortable: true,
canSort: true,
Cell: ({
row: {
original: { changed_by_name: changedByName, changedByUrl },
@ -74,7 +74,7 @@ class DashboardTable extends React.PureComponent {
{
accessor: 'changed_on',
Header: 'Modified',
sortable: true,
canSort: true,
Cell: ({
row: {
original: { changed_on: changedOn },

View File

@ -194,6 +194,7 @@ const config = {
},
},
optimization: {
sideEffects: true,
splitChunks: {
chunks: 'all',
automaticNameDelimiter: '-',
@ -223,10 +224,6 @@ const config = {
// https://github.com/mapbox/mapbox-gl-js/issues/4359#issuecomment-288001933
noParse: /(mapbox-gl)\.js$/,
rules: [
{
test: /datatables\.net.*/,
loader: 'imports-loader?define=>false',
},
{
test: /\.tsx?$/,
use: [

View File

@ -1360,6 +1360,17 @@ def get_iterable(x: Any) -> List[Any]:
return x if isinstance(x, list) else [x]
class LenientEnum(Enum):
"""Enums that do not raise ValueError when value is invalid"""
@classmethod
def get(cls, value: Any) -> Any:
try:
return super().__new__(cls, value)
except ValueError:
return None
class TimeRangeEndpoint(str, Enum):
"""
The time range endpoint types which represent inclusive, exclusive, or unknown.
@ -1406,6 +1417,15 @@ class DbColumnType(Enum):
TEMPORAL = 2
class QueryMode(str, LenientEnum):
"""
Whether the query runs on aggregate or returns raw records
"""
RAW = "raw"
AGGREGATE = "aggregate"
class FilterOperator(str, Enum):
"""
Operators used filter controls

View File

@ -60,6 +60,7 @@ from superset.utils.core import (
DTTM_ALIAS,
JS_MAX_INTEGER,
merge_extra_filters,
QueryMode,
to_adhoc,
)
@ -112,7 +113,7 @@ class BaseViz:
self.query = ""
self.token = self.form_data.get("token", "token_" + uuid.uuid4().hex[:8])
self.groupby = self.form_data.get("groupby") or []
self.groupby: List[str] = self.form_data.get("groupby") or []
self.time_shift = timedelta()
self.status: Optional[str] = None
@ -297,8 +298,10 @@ class BaseViz:
def query_obj(self) -> QueryObjectDict:
"""Building a query object"""
form_data = self.form_data
self.process_query_filters()
gb = form_data.get("groupby") or []
gb = self.groupby
metrics = self.all_metrics or []
columns = form_data.get("columns") or []
groupby = list(set(gb + columns))
@ -346,7 +349,7 @@ class BaseViz:
"where": form_data.get("where", ""),
}
d = {
return {
"granularity": granularity,
"from_dttm": from_dttm,
"to_dttm": to_dttm,
@ -360,7 +363,6 @@ class BaseViz:
"timeseries_limit_metric": timeseries_limit_metric,
"order_desc": order_desc,
}
return d
@property
def cache_timeout(self) -> int:
@ -572,6 +574,52 @@ class TableViz(BaseViz):
is_timeseries = False
enforce_numerical_metrics = False
def process_metrics(self) -> None:
"""Process form data and store parsed column configs.
1. Determine query mode based on form_data params.
- Use `query_mode` if it has a valid value
- Set as RAW mode if `all_columns` is set
- Otherwise defaults to AGG mode
2. Determine output columns based on query mode.
"""
# Verify form data first: if not specifying query mode, then cannot have both
# GROUP BY and RAW COLUMNS.
fd = self.form_data
if (
not fd.get("query_mode")
and fd.get("all_columns")
and (fd.get("groupby") or fd.get("metrics") or fd.get("percent_metrics"))
):
raise QueryObjectValidationError(
_(
"You cannot use [Columns] in combination with "
"[Group By]/[Metrics]/[Percentage Metrics]. "
"Please choose one or the other."
)
)
super().process_metrics()
self.query_mode: QueryMode = QueryMode.get(fd.get("query_mode")) or (
# infer query mode from the presence of other fields
QueryMode.RAW
if len(fd.get("all_columns") or []) > 0
else QueryMode.AGGREGATE
)
columns: List[str] = [] # output columns sans time and percent_metric column
percent_columns: List[str] = [] # percent columns that needs extra computation
if self.query_mode == QueryMode.RAW:
columns = utils.get_metric_names(fd.get("all_columns") or [])
else:
columns = utils.get_metric_names(self.groupby + (fd.get("metrics") or []))
percent_columns = utils.get_metric_names(fd.get("percent_metrics") or [])
self.columns = columns
self.percent_columns = percent_columns
self.is_timeseries = self.should_be_timeseries()
def should_be_timeseries(self) -> bool:
fd = self.form_data
# TODO handle datasource-type-specific code in datasource
@ -587,36 +635,24 @@ class TableViz(BaseViz):
def query_obj(self) -> QueryObjectDict:
d = super().query_obj()
fd = self.form_data
if fd.get("all_columns") and (
fd.get("groupby") or fd.get("metrics") or fd.get("percent_metrics")
):
raise QueryObjectValidationError(
_(
"Choose either fields to [Group By] and [Metrics] and/or "
"[Percentage Metrics], or [Columns], not both"
)
)
sort_by = fd.get("timeseries_limit_metric")
if fd.get("all_columns"):
if self.query_mode == QueryMode.RAW:
d["columns"] = fd.get("all_columns")
d["groupby"] = []
order_by_cols = fd.get("order_by_cols") or []
d["orderby"] = [json.loads(t) for t in order_by_cols]
elif sort_by:
sort_by_label = utils.get_metric_name(sort_by)
if sort_by_label not in utils.get_metric_names(d["metrics"]):
d["metrics"] += [sort_by]
d["orderby"] = [(sort_by, not fd.get("order_desc", True))]
# Add all percent metrics that are not already in the list
if "percent_metrics" in fd:
d["metrics"].extend(
m for m in fd["percent_metrics"] or [] if m not in d["metrics"]
)
d["is_timeseries"] = self.should_be_timeseries()
# must disable groupby and metrics in raw mode
d["groupby"] = []
d["metrics"] = []
# raw mode does not support timeseries queries
d["timeseries_limit_metric"] = None
d["timeseries_limit"] = None
d["is_timeseries"] = None
else:
sort_by = fd.get("timeseries_limit_metric")
if sort_by:
sort_by_label = utils.get_metric_name(sort_by)
if sort_by_label not in d["metrics"]:
d["metrics"].append(sort_by)
d["orderby"] = [(sort_by, not fd.get("order_desc", True))]
return d
def get_data(self, df: pd.DataFrame) -> VizData:
@ -630,49 +666,28 @@ class TableViz(BaseViz):
the union of the metrics representing the non-percent and percent metrics. Note
the percent metrics have yet to be transformed.
"""
non_percent_metric_columns = []
# Transform the data frame to adhere to the UI ordering of the columns and
# metrics whilst simultaneously computing the percentages (via normalization)
# for the percent metrics.
if DTTM_ALIAS in df:
if self.should_be_timeseries():
non_percent_metric_columns.append(DTTM_ALIAS)
else:
del df[DTTM_ALIAS]
non_percent_metric_columns.extend(
self.form_data.get("all_columns") or self.form_data.get("groupby") or []
)
non_percent_metric_columns.extend(
utils.get_metric_names(self.form_data.get("metrics") or [])
)
percent_metric_columns = utils.get_metric_names(
self.form_data.get("percent_metrics") or []
)
if not df.empty:
columns, percent_columns = self.columns, self.percent_columns
if DTTM_ALIAS in df and self.is_timeseries:
columns = [DTTM_ALIAS] + columns
df = pd.concat(
[
df[non_percent_metric_columns],
df[columns],
(
df[percent_metric_columns]
.div(df[percent_metric_columns].sum())
df[percent_columns]
.div(df[percent_columns].sum())
.add_prefix("%")
),
],
axis=1,
)
data = self.handle_js_int_overflow(
return self.handle_js_int_overflow(
dict(records=df.to_dict(orient="records"), columns=list(df.columns))
)
return data
def json_dumps(self, obj: Any, sort_keys: bool = False) -> str:
return json.dumps(
obj, default=utils.json_iso_dttm_ser, sort_keys=sort_keys, ignore_nan=True

View File

@ -344,33 +344,28 @@ class TableVizTestCase(SupersetTestCase):
self.assertEqual("(value3 in ('North America'))", query_obj["extras"]["where"])
self.assertEqual("", query_obj["extras"]["having"])
@patch("superset.viz.BaseViz.query_obj")
def test_query_obj_merges_percent_metrics(self, super_query_obj):
def test_query_obj_merges_percent_metrics(self):
datasource = self.get_datasource_mock()
form_data = {
"percent_metrics": ["sum__A", "avg__B", "max__Y"],
"metrics": ["sum__A", "count", "avg__C"],
"percent_metrics": ["sum__A", "avg__B", "max__Y"],
}
test_viz = viz.TableViz(datasource, form_data)
f_query_obj = {"metrics": form_data["metrics"]}
super_query_obj.return_value = f_query_obj
query_obj = test_viz.query_obj()
self.assertEqual(
["sum__A", "count", "avg__C", "avg__B", "max__Y"], query_obj["metrics"]
)
@patch("superset.viz.BaseViz.query_obj")
def test_query_obj_throws_columns_and_metrics(self, super_query_obj):
def test_query_obj_throws_columns_and_metrics(self):
datasource = self.get_datasource_mock()
form_data = {"all_columns": ["A", "B"], "metrics": ["x", "y"]}
super_query_obj.return_value = {}
test_viz = viz.TableViz(datasource, form_data)
with self.assertRaises(Exception):
test_viz = viz.TableViz(datasource, form_data)
test_viz.query_obj()
del form_data["metrics"]
form_data["groupby"] = ["B", "C"]
test_viz = viz.TableViz(datasource, form_data)
with self.assertRaises(Exception):
test_viz = viz.TableViz(datasource, form_data)
test_viz.query_obj()
@patch("superset.viz.BaseViz.query_obj")
@ -390,21 +385,35 @@ class TableVizTestCase(SupersetTestCase):
self.assertEqual([], query_obj["groupby"])
self.assertEqual([["colA", "colB"], ["colC"]], query_obj["orderby"])
@patch("superset.viz.BaseViz.query_obj")
def test_query_obj_uses_sortby(self, super_query_obj):
def test_query_obj_uses_sortby(self):
datasource = self.get_datasource_mock()
form_data = {"timeseries_limit_metric": "__time__", "order_desc": False}
super_query_obj.return_value = {"metrics": ["colA", "colB"]}
test_viz = viz.TableViz(datasource, form_data)
query_obj = test_viz.query_obj()
self.assertEqual(["colA", "colB", "__time__"], query_obj["metrics"])
self.assertEqual([("__time__", True)], query_obj["orderby"])
form_data = {
"metrics": ["colA", "colB"],
"order_desc": False,
}
def run_test(metric):
form_data["timeseries_limit_metric"] = metric
test_viz = viz.TableViz(datasource, form_data)
query_obj = test_viz.query_obj()
self.assertEqual(["colA", "colB", metric], query_obj["metrics"])
self.assertEqual([(metric, True)], query_obj["orderby"])
run_test("simple_metric")
run_test(
{
"label": "adhoc_metric",
"expressionType": "SIMPLE",
"aggregate": "SUM",
"column": {"column_name": "sort_column",},
}
)
def test_should_be_timeseries_raises_when_no_granularity(self):
datasource = self.get_datasource_mock()
form_data = {"include_time": True}
test_viz = viz.TableViz(datasource, form_data)
with self.assertRaises(Exception):
test_viz = viz.TableViz(datasource, form_data)
test_viz.should_be_timeseries()
def test_adhoc_metric_with_sortby(self):