mirror of https://github.com/apache/superset.git
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:
parent
3414f35792
commit
9bdfa055ac
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
)
|
||||
) {
|
||||
|
|
|
@ -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>],
|
||||
[
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
|
|
133
superset/viz.py
133
superset/viz.py
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue