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);
|
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
|
// should hide the filter indicator
|
||||||
cy.get('.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",
|
"@emotion/core": "^10.0.28",
|
||||||
"@superset-ui/chart": "^0.14.1",
|
"@superset-ui/chart": "^0.14.1",
|
||||||
"@superset-ui/chart-composition": "^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/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/core": "^0.14.0",
|
||||||
"@superset-ui/dimension": "^0.14.0",
|
"@superset-ui/dimension": "^0.14.0",
|
||||||
"@superset-ui/legacy-plugin-chart-calendar": "^0.14.1",
|
"@superset-ui/legacy-plugin-chart-calendar": "^0.14.1",
|
||||||
"@superset-ui/legacy-plugin-chart-chord": "^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-event-flow": "^0.14.1",
|
||||||
"@superset-ui/legacy-plugin-chart-force-directed": "^0.14.1",
|
"@superset-ui/legacy-plugin-chart-force-directed": "^0.14.1",
|
||||||
"@superset-ui/legacy-plugin-chart-heatmap": "^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": "^0.14.1",
|
||||||
"@superset-ui/legacy-plugin-chart-sankey-loop": "^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-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-treemap": "^0.14.1",
|
||||||
"@superset-ui/legacy-plugin-chart-world-map": "^0.14.1",
|
"@superset-ui/legacy-plugin-chart-world-map": "^0.14.1",
|
||||||
"@superset-ui/legacy-preset-chart-big-number": "^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/preset-chart-xy": "^0.14.1",
|
||||||
"@superset-ui/query": "^0.14.1",
|
"@superset-ui/query": "^0.14.1",
|
||||||
"@superset-ui/style": "^0.14.0",
|
"@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/time-format": "^0.14.1",
|
||||||
"@superset-ui/translation": "^0.14.0",
|
"@superset-ui/translation": "^0.14.0",
|
||||||
"@superset-ui/validator": "^0.14.1",
|
"@superset-ui/validator": "^0.14.1",
|
||||||
|
@ -110,6 +110,7 @@
|
||||||
"@types/redux-localstorage": "^1.0.8",
|
"@types/redux-localstorage": "^1.0.8",
|
||||||
"@types/rison": "0.0.6",
|
"@types/rison": "0.0.6",
|
||||||
"@vx/responsive": "^0.0.195",
|
"@vx/responsive": "^0.0.195",
|
||||||
|
"memoize-one": "^5.1.1",
|
||||||
"abortcontroller-polyfill": "^1.1.9",
|
"abortcontroller-polyfill": "^1.1.9",
|
||||||
"aphrodite": "^2.3.1",
|
"aphrodite": "^2.3.1",
|
||||||
"array-move": "^2.2.1",
|
"array-move": "^2.2.1",
|
||||||
|
@ -166,7 +167,7 @@
|
||||||
"react-split": "^2.0.4",
|
"react-split": "^2.0.4",
|
||||||
"react-sticky": "^6.0.2",
|
"react-sticky": "^6.0.2",
|
||||||
"react-syntax-highlighter": "^7.0.4",
|
"react-syntax-highlighter": "^7.0.4",
|
||||||
"react-table": "^7.0.4",
|
"react-table": "^7.2.1",
|
||||||
"react-transition-group": "^2.5.3",
|
"react-transition-group": "^2.5.3",
|
||||||
"react-ultimate-pagination": "^1.2.0",
|
"react-ultimate-pagination": "^1.2.0",
|
||||||
"react-virtualized": "9.19.1",
|
"react-virtualized": "9.19.1",
|
||||||
|
@ -178,7 +179,7 @@
|
||||||
"redux-localstorage": "^0.4.1",
|
"redux-localstorage": "^0.4.1",
|
||||||
"redux-thunk": "^2.1.0",
|
"redux-thunk": "^2.1.0",
|
||||||
"redux-undo": "^1.0.0-beta9-9-7",
|
"redux-undo": "^1.0.0-beta9-9-7",
|
||||||
"regenerator-runtime": "^0.13.3",
|
"regenerator-runtime": "^0.13.5",
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
"shortid": "^2.2.6",
|
"shortid": "^2.2.6",
|
||||||
"urijs": "^1.18.10",
|
"urijs": "^1.18.10",
|
||||||
|
@ -202,19 +203,19 @@
|
||||||
"@svgr/webpack": "^5.4.0",
|
"@svgr/webpack": "^5.4.0",
|
||||||
"@types/classnames": "^2.2.9",
|
"@types/classnames": "^2.2.9",
|
||||||
"@types/dom-to-image": "^2.6.0",
|
"@types/dom-to-image": "^2.6.0",
|
||||||
"@types/jest": "^25.1.4",
|
"@types/jest": "^26.0.3",
|
||||||
"@types/jquery": "^3.3.32",
|
"@types/jquery": "^3.3.32",
|
||||||
"@types/react": "^16.9.38",
|
"@types/react": "^16.9.38",
|
||||||
"@types/react-dom": "^16.9.8",
|
"@types/react-dom": "^16.9.8",
|
||||||
"@types/react-json-tree": "^0.6.11",
|
"@types/react-json-tree": "^0.6.11",
|
||||||
"@types/react-redux": "^7.1.7",
|
"@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/react-ultimate-pagination": "^1.2.0",
|
||||||
"@types/yargs": "12 - 15",
|
"@types/yargs": "12 - 15",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
"@typescript-eslint/eslint-plugin": "^2.20.0",
|
||||||
"@typescript-eslint/parser": "^2.20.0",
|
"@typescript-eslint/parser": "^2.20.0",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-jest": "^25.1.0",
|
"babel-jest": "^26.1.0",
|
||||||
"babel-loader": "^8.0.6",
|
"babel-loader": "^8.0.6",
|
||||||
"babel-plugin-dynamic-import-node": "^2.3.0",
|
"babel-plugin-dynamic-import-node": "^2.3.0",
|
||||||
"babel-plugin-emotion": "^10.0.29",
|
"babel-plugin-emotion": "^10.0.29",
|
||||||
|
@ -233,7 +234,7 @@
|
||||||
"eslint-import-resolver-webpack": "^0.10.1",
|
"eslint-import-resolver-webpack": "^0.10.1",
|
||||||
"eslint-plugin-cypress": "^2.0.1",
|
"eslint-plugin-cypress": "^2.0.1",
|
||||||
"eslint-plugin-import": "^2.2.0",
|
"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-jsx-a11y": "^5.1.1",
|
||||||
"eslint-plugin-no-only-tests": "^2.0.1",
|
"eslint-plugin-no-only-tests": "^2.0.1",
|
||||||
"eslint-plugin-prettier": "^3.1.3",
|
"eslint-plugin-prettier": "^3.1.3",
|
||||||
|
@ -244,8 +245,8 @@
|
||||||
"fork-ts-checker-webpack-plugin": "^0.4.9",
|
"fork-ts-checker-webpack-plugin": "^0.4.9",
|
||||||
"ignore-styles": "^5.0.1",
|
"ignore-styles": "^5.0.1",
|
||||||
"imports-loader": "^0.7.1",
|
"imports-loader": "^0.7.1",
|
||||||
"jest": "^25.1.0",
|
"jest": "^26.1.0",
|
||||||
"jsdom": "9.12.0",
|
"jsdom": "^16.2.2",
|
||||||
"less": "^3.9.0",
|
"less": "^3.9.0",
|
||||||
"less-loader": "^5.0.0",
|
"less-loader": "^5.0.0",
|
||||||
"mini-css-extract-plugin": "^0.4.0",
|
"mini-css-extract-plugin": "^0.4.0",
|
||||||
|
@ -261,7 +262,7 @@
|
||||||
"terser-webpack-plugin": "^1.1.0",
|
"terser-webpack-plugin": "^1.1.0",
|
||||||
"thread-loader": "^1.2.0",
|
"thread-loader": "^1.2.0",
|
||||||
"transform-loader": "^0.2.3",
|
"transform-loader": "^0.2.3",
|
||||||
"ts-jest": "^25.4.0",
|
"ts-jest": "^26.1.1",
|
||||||
"ts-loader": "^6.2.1",
|
"ts-loader": "^6.2.1",
|
||||||
"typescript": "^3.8.3",
|
"typescript": "^3.8.3",
|
||||||
"url-loader": "^1.0.1",
|
"url-loader": "^1.0.1",
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
import 'core-js/stable';
|
import 'core-js/stable';
|
||||||
import 'regenerator-runtime/runtime';
|
import 'regenerator-runtime/runtime';
|
||||||
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
|
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
|
||||||
import jsdom from 'jsdom';
|
|
||||||
import { configure } from 'enzyme';
|
import { configure } from 'enzyme';
|
||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from 'enzyme-adapter-react-16';
|
||||||
import { configure as configureTranslation } from '@superset-ui/translation';
|
import { configure as configureTranslation } from '@superset-ui/translation';
|
||||||
|
@ -31,11 +30,6 @@ configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
const exposedProperties = ['window', 'navigator', 'document'];
|
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 => {
|
Object.keys(document.defaultView).forEach(property => {
|
||||||
if (typeof global[property] === 'undefined') {
|
if (typeof global[property] === 'undefined') {
|
||||||
exposedProperties.push(property);
|
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.location = { href: 'about:blank' };
|
||||||
global.window.performance = { now: () => new Date().getTime() };
|
global.window.performance = { now: () => new Date().getTime() };
|
||||||
global.$ = require('jquery')(global.window);
|
global.$ = require('jquery')(global.window);
|
||||||
|
|
|
@ -62,7 +62,8 @@ describe('AsyncSelect', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('auto select', () => {
|
describe('auto select', () => {
|
||||||
it('should not call onChange if autoSelect=false', done => {
|
it('should not call onChange if autoSelect=false', () => {
|
||||||
|
return new Promise(done => {
|
||||||
expect.assertions(2);
|
expect.assertions(2);
|
||||||
|
|
||||||
const onChangeSpy = jest.fn();
|
const onChangeSpy = jest.fn();
|
||||||
|
@ -74,8 +75,10 @@ describe('AsyncSelect', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should auto select the first option if autoSelect=true', done => {
|
it('should auto select the first option if autoSelect=true', () => {
|
||||||
|
return new Promise(done => {
|
||||||
expect.assertions(3);
|
expect.assertions(3);
|
||||||
|
|
||||||
const onChangeSpy = jest.fn();
|
const onChangeSpy = jest.fn();
|
||||||
|
@ -86,12 +89,16 @@ describe('AsyncSelect', () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
|
expect(fetchMock.calls(dataGlob)).toHaveLength(1);
|
||||||
expect(onChangeSpy.mock.calls).toHaveLength(1);
|
expect(onChangeSpy.mock.calls).toHaveLength(1);
|
||||||
expect(onChangeSpy).toBeCalledWith(wrapper.instance().state.options[0]);
|
expect(onChangeSpy).toBeCalledWith(
|
||||||
|
wrapper.instance().state.options[0],
|
||||||
|
);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not auto select when value prop is set and autoSelect=true', done => {
|
it('should not auto select when value prop is set and autoSelect=true', () => {
|
||||||
|
return new Promise(done => {
|
||||||
expect.assertions(3);
|
expect.assertions(3);
|
||||||
|
|
||||||
const onChangeSpy = jest.fn();
|
const onChangeSpy = jest.fn();
|
||||||
|
@ -111,6 +118,7 @@ describe('AsyncSelect', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should call onAsyncError if there is an error fetching options', () => {
|
it('should call onAsyncError if there is an error fetching options', () => {
|
||||||
expect.assertions(3);
|
expect.assertions(3);
|
||||||
|
|
|
@ -30,7 +30,7 @@ import Pagination from 'src/components/Pagination';
|
||||||
import { areArraysShallowEqual } from 'src/reduxUtils';
|
import { areArraysShallowEqual } from 'src/reduxUtils';
|
||||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||||
|
|
||||||
export function makeMockLocation(query) {
|
function makeMockLocation(query) {
|
||||||
const queryStr = encodeURIComponent(query);
|
const queryStr = encodeURIComponent(query);
|
||||||
return {
|
return {
|
||||||
protocol: 'http:',
|
protocol: 'http:',
|
||||||
|
@ -292,16 +292,14 @@ Array [
|
||||||
...mockedProps,
|
...mockedProps,
|
||||||
filters: [...mockedProps.filters, { id: 'some_column' }],
|
filters: [...mockedProps.filters, { id: 'some_column' }],
|
||||||
};
|
};
|
||||||
try {
|
expect(() => {
|
||||||
shallow(<ListView {...props} />, {
|
shallow(<ListView {...props} />, {
|
||||||
wrappingComponent: ThemeProvider,
|
wrappingComponent: ThemeProvider,
|
||||||
wrappingComponentProps: { theme: supersetTheme },
|
wrappingComponentProps: { theme: supersetTheme },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
}).toThrowErrorMatchingInlineSnapshot(
|
||||||
expect(e).toMatchInlineSnapshot(
|
'"Invalid filter config, some_column is not present in columns"',
|
||||||
`[ListViewError: Invalid filter config, some_column is not present in columns]`,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -176,7 +176,7 @@ describe('TableSelector', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test needs to be fixed: Github issue #7768
|
// 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(
|
fetchMock.get(
|
||||||
FETCH_TABLES_GLOB,
|
FETCH_TABLES_GLOB,
|
||||||
{ throws: 'error' },
|
{ throws: 'error' },
|
||||||
|
@ -218,7 +218,7 @@ describe('TableSelector', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test needs to be fixed: Github issue #7768
|
// 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();
|
const handleErrors = sinon.stub();
|
||||||
expect(handleErrors.callCount).toBe(0);
|
expect(handleErrors.callCount).toBe(0);
|
||||||
wrapper.setProps({ handleErrors });
|
wrapper.setProps({ handleErrors });
|
||||||
|
|
|
@ -70,7 +70,8 @@ describe('ChangeDatasourceModal', () => {
|
||||||
expect(wrapper.find(Modal)).toHaveLength(1);
|
expect(wrapper.find(Modal)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches datasources', done => {
|
it('fetches datasources', () => {
|
||||||
|
return new Promise(done => {
|
||||||
inst.onEnterModal();
|
inst.onEnterModal();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1);
|
expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1);
|
||||||
|
@ -78,8 +79,10 @@ describe('ChangeDatasourceModal', () => {
|
||||||
done();
|
done();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('changes the datasource', done => {
|
it('changes the datasource', () => {
|
||||||
|
return new Promise(done => {
|
||||||
fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
|
fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
|
||||||
inst.selectDatasource(datasourceData);
|
inst.selectDatasource(datasourceData);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -91,4 +94,5 @@ describe('ChangeDatasourceModal', () => {
|
||||||
done();
|
done();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -70,7 +70,8 @@ describe('DatasourceEditor', () => {
|
||||||
expect(wrapper.find(Tabs)).toHaveLength(1);
|
expect(wrapper.find(Tabs)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes an async request', done => {
|
it('makes an async request', () => {
|
||||||
|
return new Promise(done => {
|
||||||
wrapper.setState({ activeTabKey: 2 });
|
wrapper.setState({ activeTabKey: 2 });
|
||||||
const syncButton = wrapper.find('.sync-from-source');
|
const syncButton = wrapper.find('.sync-from-source');
|
||||||
expect(syncButton).toHaveLength(1);
|
expect(syncButton).toHaveLength(1);
|
||||||
|
@ -82,6 +83,7 @@ describe('DatasourceEditor', () => {
|
||||||
done();
|
done();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('merges columns', () => {
|
it('merges columns', () => {
|
||||||
const numCols = props.datasource.columns.length;
|
const numCols = props.datasource.columns.length;
|
||||||
|
|
|
@ -68,7 +68,8 @@ describe('DatasourceModal', () => {
|
||||||
expect(wrapper.find(DatasourceEditor)).toHaveLength(1);
|
expect(wrapper.find(DatasourceEditor)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('saves on confirm', done => {
|
it('saves on confirm', () => {
|
||||||
|
return new Promise(done => {
|
||||||
inst.onConfirmSave();
|
inst.onConfirmSave();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect(fetchMock.calls(SAVE_ENDPOINT)).toHaveLength(1);
|
expect(fetchMock.calls(SAVE_ENDPOINT)).toHaveLength(1);
|
||||||
|
@ -77,4 +78,5 @@ describe('DatasourceModal', () => {
|
||||||
done();
|
done();
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -181,7 +181,8 @@ describe('MetricsControl', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles aggregates being selected', done => {
|
it('handles aggregates being selected', () => {
|
||||||
|
return new Promise(done => {
|
||||||
const { wrapper, onChange } = setup();
|
const { wrapper, onChange } = setup();
|
||||||
const select = wrapper.find(OnPasteSelect);
|
const select = wrapper.find(OnPasteSelect);
|
||||||
|
|
||||||
|
@ -198,7 +199,9 @@ describe('MetricsControl', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
select.simulate('change', [{ aggregate_name: 'SUM', optionName: 'SUM' }]);
|
select.simulate('change', [
|
||||||
|
{ aggregate_name: 'SUM', optionName: 'SUM' },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(instance.select.inputRef.value).toBe('SUM()');
|
expect(instance.select.inputRef.value).toBe('SUM()');
|
||||||
expect(handleInputChangeSpy).toHaveBeenCalledWith({
|
expect(handleInputChangeSpy).toHaveBeenCalledWith({
|
||||||
|
@ -213,6 +216,7 @@ describe('MetricsControl', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves existing selected AdhocMetrics', () => {
|
it('preserves existing selected AdhocMetrics', () => {
|
||||||
const { wrapper, onChange } = setup();
|
const { wrapper, onChange } = setup();
|
||||||
|
|
|
@ -210,7 +210,8 @@ describe('SaveModal', () => {
|
||||||
Object.defineProperty(window, 'location', windowLocation);
|
Object.defineProperty(window, 'location', windowLocation);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Save & go to dashboard', done => {
|
it('Save & go to dashboard', () => {
|
||||||
|
return new Promise(done => {
|
||||||
wrapper.instance().saveOrOverwrite(true);
|
wrapper.instance().saveOrOverwrite(true);
|
||||||
defaultProps.actions.saveSlice().then(() => {
|
defaultProps.actions.saveSlice().then(() => {
|
||||||
expect(window.location.assign.callCount).toEqual(1);
|
expect(window.location.assign.callCount).toEqual(1);
|
||||||
|
@ -220,9 +221,14 @@ describe('SaveModal', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('saveas new slice', done => {
|
it('saveas new slice', () => {
|
||||||
wrapper.setState({ action: 'saveas', newSliceName: 'new slice name' });
|
return new Promise(done => {
|
||||||
|
wrapper.setState({
|
||||||
|
action: 'saveas',
|
||||||
|
newSliceName: 'new slice name',
|
||||||
|
});
|
||||||
wrapper.instance().saveOrOverwrite(false);
|
wrapper.instance().saveOrOverwrite(false);
|
||||||
defaultProps.actions.saveSlice().then(() => {
|
defaultProps.actions.saveSlice().then(() => {
|
||||||
expect(window.location.assign.callCount).toEqual(1);
|
expect(window.location.assign.callCount).toEqual(1);
|
||||||
|
@ -232,8 +238,10 @@ describe('SaveModal', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('overwrite original slice', done => {
|
it('overwrite original slice', () => {
|
||||||
|
return new Promise(done => {
|
||||||
wrapper.setState({ action: 'overwrite' });
|
wrapper.setState({ action: 'overwrite' });
|
||||||
wrapper.instance().saveOrOverwrite(false);
|
wrapper.instance().saveOrOverwrite(false);
|
||||||
defaultProps.actions.saveSlice().then(() => {
|
defaultProps.actions.saveSlice().then(() => {
|
||||||
|
@ -246,6 +254,7 @@ describe('SaveModal', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fetchDashboards', () => {
|
describe('fetchDashboards', () => {
|
||||||
let dispatch;
|
let dispatch;
|
||||||
|
|
|
@ -105,6 +105,8 @@ describe('controlUtils', () => {
|
||||||
expanded: true,
|
expanded: true,
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
[
|
[
|
||||||
|
'metric',
|
||||||
|
'metrics',
|
||||||
{
|
{
|
||||||
name: 'all_columns',
|
name: 'all_columns',
|
||||||
config: {
|
config: {
|
||||||
|
@ -213,7 +215,12 @@ describe('controlUtils', () => {
|
||||||
expect(control.value).toBe('stack');
|
expect(control.value).toBe('stack');
|
||||||
|
|
||||||
control = getControlState('stacked_style', 'test-chart', state, 'FOO');
|
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', () => {
|
it('applies the default function for metrics', () => {
|
||||||
|
@ -254,7 +261,11 @@ describe('controlUtils', () => {
|
||||||
it('in formData', () => {
|
it('in formData', () => {
|
||||||
const controlsState = getAllControlsState('table', 'table', {}, {});
|
const controlsState = getAllControlsState('table', 'table', {}, {});
|
||||||
const formData = getFormDataFromControls(controlsState);
|
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';
|
import * as actions from 'src/explore/actions/exploreActions';
|
||||||
|
|
||||||
describe('reducers', () => {
|
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(
|
const newState = exploreReducer(
|
||||||
defaultState,
|
defaultState,
|
||||||
actions.setControlValue('x_axis_label', 'x', []),
|
actions.setControlValue('NEW_FIELD', 'x', []),
|
||||||
);
|
);
|
||||||
expect(newState.controls.x_axis_label.value).toBe('x');
|
expect(newState.controls.NEW_FIELD.value).toBe('x');
|
||||||
expect(newState.form_data.x_axis_label).toBe('x');
|
expect(newState.form_data.NEW_FIELD).toBe('x');
|
||||||
});
|
});
|
||||||
it('setControlValue works as expected with a checkbox', () => {
|
it('setControlValue works as expected with a checkbox', () => {
|
||||||
const newState = exploreReducer(
|
const newState = exploreReducer(
|
||||||
|
|
|
@ -46,7 +46,8 @@ describe('Toast', () => {
|
||||||
expect(alert.childAt(0).childAt(1).text()).toBe(props.toast.text);
|
expect(alert.childAt(0).childAt(1).text()).toBe(props.toast.text);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onCloseToast upon alert dismissal', done => {
|
it('should call onCloseToast upon alert dismissal', () => {
|
||||||
|
return new Promise(done => {
|
||||||
const onCloseToast = id => {
|
const onCloseToast = id => {
|
||||||
expect(id).toBe(props.toast.id);
|
expect(id).toBe(props.toast.id);
|
||||||
done();
|
done();
|
||||||
|
@ -56,4 +57,5 @@ describe('Toast', () => {
|
||||||
expect(wrapper.find(Alert).prop('onDismiss')).toBe(handleClosePress);
|
expect(wrapper.find(Alert).prop('onDismiss')).toBe(handleClosePress);
|
||||||
handleClosePress(); // there is a timeout for onCloseToast to be called
|
handleClosePress(); // there is a timeout for onCloseToast to be called
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -76,7 +76,6 @@ describe('EditableTitle', () => {
|
||||||
describe('should handle blur', () => {
|
describe('should handle blur', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
editableWrapper.find('input').simulate('click');
|
editableWrapper.find('input').simulate('click');
|
||||||
expect(editableWrapper.find('input').props().type).toBe('text');
|
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
callback.resetHistory();
|
callback.resetHistory();
|
||||||
|
@ -84,6 +83,10 @@ describe('EditableTitle', () => {
|
||||||
editableWrapper.setState({ lastTitle: 'my title' });
|
editableWrapper.setState({ lastTitle: 'my title' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('default input type should be text', () => {
|
||||||
|
expect(editableWrapper.find('input').props().type).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
it('should trigger callback', () => {
|
it('should trigger callback', () => {
|
||||||
editableWrapper.setState({ title: 'new title' });
|
editableWrapper.setState({ title: 'new title' });
|
||||||
editableWrapper.find('input').simulate('blur');
|
editableWrapper.find('input').simulate('blur');
|
||||||
|
|
|
@ -193,7 +193,8 @@ describe('ExploreResultsButton', () => {
|
||||||
fetchMock.reset();
|
fetchMock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should build request with correct args', done => {
|
it('should build request with correct args', () => {
|
||||||
|
return new Promise(done => {
|
||||||
wrapper.instance().visualize();
|
wrapper.instance().visualize();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -209,8 +210,10 @@ describe('ExploreResultsButton', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should export chart and add an info toast', done => {
|
it('should export chart and add an info toast', () => {
|
||||||
|
return new Promise(done => {
|
||||||
const infoToastSpy = sinon.spy();
|
const infoToastSpy = sinon.spy();
|
||||||
const datasourceSpy = sinon.stub();
|
const datasourceSpy = sinon.stub();
|
||||||
|
|
||||||
|
@ -235,8 +238,10 @@ describe('ExploreResultsButton', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should add error toast', done => {
|
it('should add error toast', () => {
|
||||||
|
return new Promise(done => {
|
||||||
const dangerToastSpy = sinon.stub(actions, 'addDangerToast');
|
const dangerToastSpy = sinon.stub(actions, 'addDangerToast');
|
||||||
const datasourceSpy = sinon.stub();
|
const datasourceSpy = sinon.stub();
|
||||||
|
|
||||||
|
@ -260,4 +265,5 @@ describe('ExploreResultsButton', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(() => {
|
makeRequest().then(() => {
|
||||||
expect(fetchMock.calls(fetchQueryEndpoint)).toHaveLength(1);
|
expect(fetchMock.calls(fetchQueryEndpoint)).toHaveLength(1);
|
||||||
expect(dispatch.callCount).toBe(2);
|
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(() => {
|
makeRequest().then(() => {
|
||||||
expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1);
|
expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1);
|
||||||
expect(dispatch.callCount).toBe(2);
|
expect(dispatch.callCount).toBe(2);
|
||||||
|
|
|
@ -47,7 +47,8 @@ function setup() {
|
||||||
describe('DashboardTable', () => {
|
describe('DashboardTable', () => {
|
||||||
beforeEach(fetchMock.resetHistory);
|
beforeEach(fetchMock.resetHistory);
|
||||||
|
|
||||||
it('fetches dashboards and renders a ListView', done => {
|
it('fetches dashboards and renders a ListView', () => {
|
||||||
|
return new Promise(done => {
|
||||||
const wrapper = setup();
|
const wrapper = setup();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -59,4 +60,5 @@ describe('DashboardTable', () => {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -196,12 +196,22 @@ class ChartRenderer extends React.Component {
|
||||||
? `superset-chart-${snakeCaseVizType}`
|
? `superset-chart-${snakeCaseVizType}`
|
||||||
: 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 (
|
return (
|
||||||
<SuperChart
|
<SuperChart
|
||||||
disableErrorBoundary
|
disableErrorBoundary
|
||||||
key={`${chartId}${
|
key={`${chartId}${webpackHash}`}
|
||||||
process.env.WEBPACK_MODE === 'development' ? `-${Date.now()}` : ''
|
|
||||||
}`}
|
|
||||||
id={`chart-id-${chartId}`}
|
id={`chart-id-${chartId}`}
|
||||||
className={chartClassName}
|
className={chartClassName}
|
||||||
chartType={vizType}
|
chartType={vizType}
|
||||||
|
|
|
@ -103,7 +103,7 @@ export default function TableCollection({
|
||||||
return column.hidden ? null : (
|
return column.hidden ? null : (
|
||||||
<th
|
<th
|
||||||
{...column.getHeaderProps(
|
{...column.getHeaderProps(
|
||||||
column.sortable ? column.getSortByToggleProps() : {},
|
column.canSort ? column.getSortByToggleProps() : {},
|
||||||
)}
|
)}
|
||||||
data-test="sort-header"
|
data-test="sort-header"
|
||||||
className={cx({
|
className={cx({
|
||||||
|
@ -112,7 +112,7 @@ export default function TableCollection({
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{column.render('Header')}
|
{column.render('Header')}
|
||||||
{column.sortable && sortIcon}
|
{column.canSort && sortIcon}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
|
|
|
@ -27,7 +27,10 @@ const controlTypes = Object.keys(controlMap);
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
actions: PropTypes.object.isRequired,
|
actions: PropTypes.object.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
type: PropTypes.oneOf(controlTypes).isRequired,
|
type: PropTypes.oneOfType([
|
||||||
|
PropTypes.oneOf(controlTypes).isRequired,
|
||||||
|
PropTypes.func.isRequired,
|
||||||
|
]),
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
choices: PropTypes.oneOfType([
|
choices: PropTypes.oneOfType([
|
||||||
|
@ -62,6 +65,8 @@ export default class Control extends React.PureComponent {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hovered: false };
|
this.state = { hovered: false };
|
||||||
this.onChange = this.onChange.bind(this);
|
this.onChange = this.onChange.bind(this);
|
||||||
|
this.onMouseEnter = this.setHover.bind(this, true);
|
||||||
|
this.onMouseLeave = this.setHover.bind(this, false);
|
||||||
}
|
}
|
||||||
onChange(value, errors) {
|
onChange(value, errors) {
|
||||||
this.props.actions.setControlValue(this.props.name, 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 });
|
this.setState({ hovered });
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
if (!this.props.type) return null; // this catches things like <hr/> elements (not a control!) shoved into the control panel configs.
|
const { type, hidden } = this.props;
|
||||||
const ControlType = controlMap[this.props.type];
|
if (!type) return null;
|
||||||
const divStyle = this.props.hidden ? { display: 'none' } : null;
|
const ControlComponent = typeof type === 'string' ? controlMap[type] : type;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="Control"
|
className="Control"
|
||||||
data-test={this.props.name}
|
data-test={this.props.name}
|
||||||
style={divStyle}
|
style={hidden ? { display: 'none' } : undefined}
|
||||||
onMouseEnter={this.setHover.bind(this, true)}
|
onMouseEnter={this.onMouseEnter}
|
||||||
onMouseLeave={this.setHover.bind(this, false)}
|
onMouseLeave={this.onMouseLeave}
|
||||||
>
|
>
|
||||||
<ControlType
|
<ControlComponent
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
hovered={this.state.hovered}
|
hovered={this.state.hovered}
|
||||||
{...this.props}
|
{...this.props}
|
||||||
|
|
|
@ -25,11 +25,11 @@ import { Alert, Tab, Tabs } from 'react-bootstrap';
|
||||||
import { isPlainObject } from 'lodash';
|
import { isPlainObject } from 'lodash';
|
||||||
import { t } from '@superset-ui/translation';
|
import { t } from '@superset-ui/translation';
|
||||||
import { getChartControlPanelRegistry } from '@superset-ui/chart';
|
import { getChartControlPanelRegistry } from '@superset-ui/chart';
|
||||||
|
import { sharedControls } from '@superset-ui/chart-controls';
|
||||||
|
|
||||||
import ControlPanelSection from './ControlPanelSection';
|
import ControlPanelSection from './ControlPanelSection';
|
||||||
import ControlRow from './ControlRow';
|
import ControlRow from './ControlRow';
|
||||||
import Control from './Control';
|
import Control from './Control';
|
||||||
import controlConfigs from '../controls';
|
|
||||||
import { sectionsToRender } from '../controlUtils';
|
import { sectionsToRender } from '../controlUtils';
|
||||||
import * as exploreActions from '../actions/exploreActions';
|
import * as exploreActions from '../actions/exploreActions';
|
||||||
|
|
||||||
|
@ -47,42 +47,11 @@ class ControlPanelsContainer extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.getControlData = this.getControlData.bind(this);
|
|
||||||
this.removeAlert = this.removeAlert.bind(this);
|
this.removeAlert = this.removeAlert.bind(this);
|
||||||
this.renderControl = this.renderControl.bind(this);
|
this.renderControl = this.renderControl.bind(this);
|
||||||
this.renderControlPanelSection = this.renderControlPanelSection.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() {
|
sectionsToRender() {
|
||||||
return sectionsToRender(
|
return sectionsToRender(
|
||||||
this.props.form_data.viz_type,
|
this.props.form_data.viz_type,
|
||||||
|
@ -94,48 +63,45 @@ class ControlPanelsContainer extends React.Component {
|
||||||
this.props.actions.removeControlPanelAlert();
|
this.props.actions.removeControlPanelAlert();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderControl(name, config, lookupControlData) {
|
renderControl({ name, config }) {
|
||||||
const { actions, controls, exploreState, form_data: formData } = this.props;
|
const { actions, controls, exploreState, form_data: formData } = this.props;
|
||||||
const { visibility } = config;
|
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
|
// If the control item is not an object, we have to look up the control data from
|
||||||
// the centralized controls file.
|
// the centralized controls file.
|
||||||
// When it is an object we read control data straight from `config` instead
|
// 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
|
// if visibility check says the config is not visible, don't render it
|
||||||
const additionalProps = mapFn
|
if (visibility && !visibility.call(config, this.props, controlData)) {
|
||||||
? { ...controlData, ...mapFn(exploreState, controlData, actions) }
|
return null;
|
||||||
: controlData;
|
}
|
||||||
|
|
||||||
const { validationErrors, provideFormDataToProps } = controlData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Control
|
<Control
|
||||||
name={name}
|
name={name}
|
||||||
key={`control-${name}`}
|
key={`control-${name}`}
|
||||||
value={formData[name]}
|
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
formData={provideFormDataToProps ? formData : null}
|
formData={provideFormDataToProps ? formData : null}
|
||||||
{...additionalProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -160,54 +126,51 @@ class ControlPanelsContainer extends React.Component {
|
||||||
hasErrors={hasErrors}
|
hasErrors={hasErrors}
|
||||||
description={section.description}
|
description={section.description}
|
||||||
>
|
>
|
||||||
{section.controlSetRows.map((controlSets, i) => (
|
{section.controlSetRows.map((controlSets, i) => {
|
||||||
<ControlRow
|
const renderedControls = controlSets
|
||||||
key={`controlsetrow-${i}`}
|
.map(controlItem => {
|
||||||
className="control-row"
|
|
||||||
controls={controlSets.map(controlItem => {
|
|
||||||
if (!controlItem) {
|
if (!controlItem) {
|
||||||
// When the item is invalid
|
// When the item is invalid
|
||||||
return null;
|
return null;
|
||||||
} else if (React.isValidElement(controlItem)) {
|
} else if (React.isValidElement(controlItem)) {
|
||||||
// When the item is a React element
|
// When the item is a React element
|
||||||
return controlItem;
|
return controlItem;
|
||||||
} else if (
|
} else if (controlItem.name && controlItem.config) {
|
||||||
isPlainObject(controlItem) &&
|
return this.renderControl(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);
|
|
||||||
}
|
}
|
||||||
return null;
|
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>
|
</ControlPanelSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
const allSectionsToRender = this.sectionsToRender();
|
|
||||||
const querySectionsToRender = [];
|
const querySectionsToRender = [];
|
||||||
const displaySectionsToRender = [];
|
const displaySectionsToRender = [];
|
||||||
allSectionsToRender.forEach(section => {
|
this.sectionsToRender().forEach(section => {
|
||||||
// if at least one control in the secion is not `renderTrigger`
|
// if at least one control in the section is not `renderTrigger`
|
||||||
// or asks to be displayed at the Data tab
|
// or asks to be displayed at the Data tab
|
||||||
if (
|
if (
|
||||||
section.tabOverride === 'data' ||
|
section.tabOverride === 'data' ||
|
||||||
section.controlSetRows.some(rows =>
|
section.controlSetRows.some(rows =>
|
||||||
rows.some(
|
rows.some(
|
||||||
control =>
|
control =>
|
||||||
controlConfigs[control] &&
|
control &&
|
||||||
(!controlConfigs[control].renderTrigger ||
|
control.config &&
|
||||||
controlConfigs[control].tabOverride === 'data'),
|
(!control.config.renderTrigger ||
|
||||||
|
control.config.tabOverride === 'data'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -243,6 +243,7 @@ export const NVD3TimeSeries = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[<h1 className="section-header">{t('Python Functions')}</h1>],
|
[<h1 className="section-header">{t('Python Functions')}</h1>],
|
||||||
|
// eslint-disable-next-line jsx-a11y/heading-has-content
|
||||||
[<h2 className="section-header">pandas.resample</h2>],
|
[<h2 className="section-header">pandas.resample</h2>],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
|
@ -16,7 +16,9 @@
|
||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import memoizeOne from 'memoize-one';
|
||||||
import { getChartControlPanelRegistry } from '@superset-ui/chart';
|
import { getChartControlPanelRegistry } from '@superset-ui/chart';
|
||||||
|
import { expandControlConfig } from '@superset-ui/chart-controls';
|
||||||
import { controls as SHARED_CONTROLS } from './controls';
|
import { controls as SHARED_CONTROLS } from './controls';
|
||||||
import * as SECTIONS from './controlPanels/sections';
|
import * as SECTIONS from './controlPanels/sections';
|
||||||
|
|
||||||
|
@ -32,13 +34,13 @@ export function getFormDataFromControls(controlsState) {
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateControl(control) {
|
export function validateControl(control, processedState) {
|
||||||
const validators = control.validators;
|
const validators = control.validators;
|
||||||
if (validators && validators.length > 0) {
|
if (validators && validators.length > 0) {
|
||||||
const validatedControl = { ...control };
|
const validatedControl = { ...control };
|
||||||
const validationErrors = [];
|
const validationErrors = [];
|
||||||
validators.forEach(f => {
|
validators.forEach(f => {
|
||||||
const v = f(control.value);
|
const v = f.call(control, control.value, processedState);
|
||||||
if (v) {
|
if (v) {
|
||||||
validationErrors.push(v);
|
validationErrors.push(v);
|
||||||
}
|
}
|
||||||
|
@ -49,15 +51,20 @@ export function validateControl(control) {
|
||||||
return 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 section of controlPanelSections) {
|
||||||
for (const controlArr of section.controlSetRows) {
|
for (const controlArr of section.controlSetRows) {
|
||||||
for (const control of controlArr) {
|
for (const control of controlArr) {
|
||||||
if (control != null && typeof control === 'object') {
|
if (controlKey === control) return control;
|
||||||
if (control.config && control.name === controlKey) {
|
if (
|
||||||
return control.config;
|
control !== null &&
|
||||||
}
|
typeof control === 'object' &&
|
||||||
|
control.name === controlKey
|
||||||
|
) {
|
||||||
|
return control;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,23 +72,22 @@ function findCustomControl(controlPanelSections, controlKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getControlConfig(controlKey, vizType) {
|
export const getControlConfig = memoizeOne(function getControlConfig(
|
||||||
|
controlKey,
|
||||||
|
vizType,
|
||||||
|
) {
|
||||||
const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
|
const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
|
||||||
const {
|
const {
|
||||||
controlOverrides = {},
|
controlOverrides = {},
|
||||||
controlPanelSections = [],
|
controlPanelSections = [],
|
||||||
} = controlPanelConfig;
|
} = controlPanelConfig;
|
||||||
|
|
||||||
const config =
|
const control = expandControlConfig(
|
||||||
controlKey in SHARED_CONTROLS
|
findControlItem(controlPanelSections, controlKey),
|
||||||
? SHARED_CONTROLS[controlKey]
|
controlOverrides,
|
||||||
: findCustomControl(controlPanelSections, controlKey);
|
);
|
||||||
|
return control?.config || control;
|
||||||
return {
|
});
|
||||||
...config,
|
|
||||||
...controlOverrides[controlKey],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyMapStateToPropsToControl(control, state) {
|
export function applyMapStateToPropsToControl(control, state) {
|
||||||
if (control.mapStateToProps) {
|
if (control.mapStateToProps) {
|
||||||
|
@ -118,6 +124,10 @@ function handleMissingChoice(control) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getControlStateFromControlConfig(controlConfig, state, value) {
|
export function getControlStateFromControlConfig(controlConfig, state, value) {
|
||||||
|
// skip invalid config values
|
||||||
|
if (!controlConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const controlState = applyMapStateToPropsToControl(
|
const controlState = applyMapStateToPropsToControl(
|
||||||
{ ...controlConfig },
|
{ ...controlConfig },
|
||||||
state,
|
state,
|
||||||
|
@ -134,7 +144,7 @@ export function getControlStateFromControlConfig(controlConfig, state, value) {
|
||||||
controlState.value =
|
controlState.value =
|
||||||
typeof controlValue === 'undefined' ? controlState.default : controlValue;
|
typeof controlValue === 'undefined' ? controlState.default : controlValue;
|
||||||
|
|
||||||
return validateControl(handleMissingChoice(controlState));
|
return validateControl(handleMissingChoice(controlState), controlState);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getControlState(controlKey, vizType, state, value) {
|
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 controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
|
||||||
const {
|
const {
|
||||||
sectionOverrides = {},
|
sectionOverrides = {},
|
||||||
|
controlOverrides,
|
||||||
controlPanelSections = [],
|
controlPanelSections = [],
|
||||||
} = controlPanelConfig;
|
} = controlPanelConfig;
|
||||||
|
|
||||||
|
// default control panel sections
|
||||||
const sections = { ...SECTIONS };
|
const sections = { ...SECTIONS };
|
||||||
|
|
||||||
|
// apply section overrides
|
||||||
Object.entries(sectionOverrides).forEach(([section, overrides]) => {
|
Object.entries(sectionOverrides).forEach(([section, overrides]) => {
|
||||||
if (typeof overrides === 'object' && overrides.constructor === Object) {
|
if (typeof overrides === 'object' && overrides.constructor === Object) {
|
||||||
sections[section] = {
|
sections[section] = {
|
||||||
|
@ -171,23 +190,25 @@ export function sectionsToRender(vizType, datasourceType) {
|
||||||
|
|
||||||
return []
|
return []
|
||||||
.concat(datasourceAndVizType, timeSection, controlPanelSections)
|
.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) {
|
export function getAllControlsState(vizType, datasourceType, state, formData) {
|
||||||
const controlsState = {};
|
const controlsState = {};
|
||||||
sectionsToRender(vizType, datasourceType).forEach(section =>
|
sectionsToRender(vizType, datasourceType).forEach(section =>
|
||||||
section.controlSetRows.forEach(fieldsetRow =>
|
section.controlSetRows.forEach(fieldsetRow =>
|
||||||
fieldsetRow.forEach(field => {
|
fieldsetRow.forEach(field => {
|
||||||
if (typeof field === 'string') {
|
if (field && field.config && field.name) {
|
||||||
controlsState[field] = getControlState(
|
|
||||||
field,
|
|
||||||
vizType,
|
|
||||||
state,
|
|
||||||
formData[field],
|
|
||||||
);
|
|
||||||
} else if (field != null && typeof field === 'object') {
|
|
||||||
if (field.config && field.name) {
|
|
||||||
const { config, name } = field;
|
const { config, name } = field;
|
||||||
controlsState[name] = getControlStateFromControlConfig(
|
controlsState[name] = getControlStateFromControlConfig(
|
||||||
config,
|
config,
|
||||||
|
@ -195,10 +216,8 @@ export function getAllControlsState(vizType, datasourceType, state, formData) {
|
||||||
formData[name],
|
formData[name],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return controlsState;
|
return controlsState;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,11 @@
|
||||||
*/
|
*/
|
||||||
/* eslint camelcase: 0 */
|
/* eslint camelcase: 0 */
|
||||||
import { getControlsState } from '../store';
|
import { getControlsState } from '../store';
|
||||||
import { getControlState, getFormDataFromControls } from '../controlUtils';
|
import {
|
||||||
|
getControlConfig,
|
||||||
|
getFormDataFromControls,
|
||||||
|
getControlStateFromControlConfig,
|
||||||
|
} from '../controlUtils';
|
||||||
import * as actions from '../actions/exploreActions';
|
import * as actions from '../actions/exploreActions';
|
||||||
|
|
||||||
export default function exploreReducer(state = {}, action) {
|
export default function exploreReducer(state = {}, action) {
|
||||||
|
@ -100,8 +104,14 @@ export default function exploreReducer(state = {}, action) {
|
||||||
// These errors are reported from the Control components
|
// These errors are reported from the Control components
|
||||||
let errors = action.validationErrors || [];
|
let errors = action.validationErrors || [];
|
||||||
const vizType = new_form_data.viz_type;
|
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 = {
|
const control = {
|
||||||
...getControlState(action.controlName, vizType, state, action.value),
|
...getControlStateFromControlConfig(controlConfig, state, action.value),
|
||||||
};
|
};
|
||||||
|
|
||||||
// These errors are based on control config `validators`
|
// These errors are based on control config `validators`
|
||||||
|
|
|
@ -118,7 +118,6 @@ declare module 'react-table' {
|
||||||
UseResizeColumnsColumnOptions<D>,
|
UseResizeColumnsColumnOptions<D>,
|
||||||
UseSortByColumnOptions<D> {
|
UseSortByColumnOptions<D> {
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
sortable?: boolean;
|
|
||||||
cellProps?: any;
|
cellProps?: any;
|
||||||
size?: ColumnSize;
|
size?: ColumnSize;
|
||||||
}
|
}
|
||||||
|
@ -129,7 +128,6 @@ declare module 'react-table' {
|
||||||
UseResizeColumnsColumnProps<D>,
|
UseResizeColumnsColumnProps<D>,
|
||||||
UseSortByColumnProps<D> {
|
UseSortByColumnProps<D> {
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
sortable?: boolean;
|
|
||||||
cellProps?: any;
|
cellProps?: any;
|
||||||
size?: ColumnSize;
|
size?: ColumnSize;
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,7 +119,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}: any) => <a href={url}>{sliceName}</a>,
|
}: any) => <a href={url}>{sliceName}</a>,
|
||||||
Header: t('Chart'),
|
Header: t('Chart'),
|
||||||
accessor: 'slice_name',
|
accessor: 'slice_name',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Cell: ({
|
Cell: ({
|
||||||
|
@ -129,7 +129,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}: any) => vizType,
|
}: any) => vizType,
|
||||||
Header: t('Visualization Type'),
|
Header: t('Visualization Type'),
|
||||||
accessor: 'viz_type',
|
accessor: 'viz_type',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Cell: ({
|
Cell: ({
|
||||||
|
@ -139,7 +139,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}: any) => <a href={dsUrl}>{dsNameTxt}</a>,
|
}: any) => <a href={dsUrl}>{dsNameTxt}</a>,
|
||||||
Header: t('Datasource'),
|
Header: t('Datasource'),
|
||||||
accessor: 'datasource_name',
|
accessor: 'datasource_name',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Cell: ({
|
Cell: ({
|
||||||
|
@ -152,7 +152,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}: any) => <a href={changedByUrl}>{changedByName}</a>,
|
}: any) => <a href={changedByUrl}>{changedByName}</a>,
|
||||||
Header: t('Creator'),
|
Header: t('Creator'),
|
||||||
accessor: 'changed_by_fk',
|
accessor: 'changed_by_fk',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Cell: ({
|
Cell: ({
|
||||||
|
@ -162,7 +162,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||||
}: any) => <span className="no-wrap">{moment(changedOn).fromNow()}</span>,
|
}: any) => <span className="no-wrap">{moment(changedOn).fromNow()}</span>,
|
||||||
Header: t('Last Modified'),
|
Header: t('Last Modified'),
|
||||||
accessor: 'changed_on',
|
accessor: 'changed_on',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'description',
|
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 RoseChartPlugin from '@superset-ui/legacy-plugin-chart-rose';
|
||||||
import SankeyChartPlugin from '@superset-ui/legacy-plugin-chart-sankey';
|
import SankeyChartPlugin from '@superset-ui/legacy-plugin-chart-sankey';
|
||||||
import SunburstChartPlugin from '@superset-ui/legacy-plugin-chart-sunburst';
|
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 TreemapChartPlugin from '@superset-ui/legacy-plugin-chart-treemap';
|
||||||
import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud';
|
import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud';
|
||||||
import WorldMapChartPlugin from '@superset-ui/legacy-plugin-chart-world-map';
|
import WorldMapChartPlugin from '@superset-ui/legacy-plugin-chart-world-map';
|
||||||
|
|
|
@ -54,7 +54,7 @@ class DashboardTable extends React.PureComponent {
|
||||||
{
|
{
|
||||||
accessor: 'dashboard_title',
|
accessor: 'dashboard_title',
|
||||||
Header: 'Dashboard',
|
Header: 'Dashboard',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
Cell: ({
|
Cell: ({
|
||||||
row: {
|
row: {
|
||||||
original: { url, dashboard_title: dashboardTitle },
|
original: { url, dashboard_title: dashboardTitle },
|
||||||
|
@ -64,7 +64,7 @@ class DashboardTable extends React.PureComponent {
|
||||||
{
|
{
|
||||||
accessor: 'changed_by_fk',
|
accessor: 'changed_by_fk',
|
||||||
Header: 'Creator',
|
Header: 'Creator',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
Cell: ({
|
Cell: ({
|
||||||
row: {
|
row: {
|
||||||
original: { changed_by_name: changedByName, changedByUrl },
|
original: { changed_by_name: changedByName, changedByUrl },
|
||||||
|
@ -74,7 +74,7 @@ class DashboardTable extends React.PureComponent {
|
||||||
{
|
{
|
||||||
accessor: 'changed_on',
|
accessor: 'changed_on',
|
||||||
Header: 'Modified',
|
Header: 'Modified',
|
||||||
sortable: true,
|
canSort: true,
|
||||||
Cell: ({
|
Cell: ({
|
||||||
row: {
|
row: {
|
||||||
original: { changed_on: changedOn },
|
original: { changed_on: changedOn },
|
||||||
|
|
|
@ -194,6 +194,7 @@ const config = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimization: {
|
optimization: {
|
||||||
|
sideEffects: true,
|
||||||
splitChunks: {
|
splitChunks: {
|
||||||
chunks: 'all',
|
chunks: 'all',
|
||||||
automaticNameDelimiter: '-',
|
automaticNameDelimiter: '-',
|
||||||
|
@ -223,10 +224,6 @@ const config = {
|
||||||
// https://github.com/mapbox/mapbox-gl-js/issues/4359#issuecomment-288001933
|
// https://github.com/mapbox/mapbox-gl-js/issues/4359#issuecomment-288001933
|
||||||
noParse: /(mapbox-gl)\.js$/,
|
noParse: /(mapbox-gl)\.js$/,
|
||||||
rules: [
|
rules: [
|
||||||
{
|
|
||||||
test: /datatables\.net.*/,
|
|
||||||
loader: 'imports-loader?define=>false',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.tsx?$/,
|
||||||
use: [
|
use: [
|
||||||
|
|
|
@ -1360,6 +1360,17 @@ def get_iterable(x: Any) -> List[Any]:
|
||||||
return x if isinstance(x, list) else [x]
|
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):
|
class TimeRangeEndpoint(str, Enum):
|
||||||
"""
|
"""
|
||||||
The time range endpoint types which represent inclusive, exclusive, or unknown.
|
The time range endpoint types which represent inclusive, exclusive, or unknown.
|
||||||
|
@ -1406,6 +1417,15 @@ class DbColumnType(Enum):
|
||||||
TEMPORAL = 2
|
TEMPORAL = 2
|
||||||
|
|
||||||
|
|
||||||
|
class QueryMode(str, LenientEnum):
|
||||||
|
"""
|
||||||
|
Whether the query runs on aggregate or returns raw records
|
||||||
|
"""
|
||||||
|
|
||||||
|
RAW = "raw"
|
||||||
|
AGGREGATE = "aggregate"
|
||||||
|
|
||||||
|
|
||||||
class FilterOperator(str, Enum):
|
class FilterOperator(str, Enum):
|
||||||
"""
|
"""
|
||||||
Operators used filter controls
|
Operators used filter controls
|
||||||
|
|
129
superset/viz.py
129
superset/viz.py
|
@ -60,6 +60,7 @@ from superset.utils.core import (
|
||||||
DTTM_ALIAS,
|
DTTM_ALIAS,
|
||||||
JS_MAX_INTEGER,
|
JS_MAX_INTEGER,
|
||||||
merge_extra_filters,
|
merge_extra_filters,
|
||||||
|
QueryMode,
|
||||||
to_adhoc,
|
to_adhoc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -112,7 +113,7 @@ class BaseViz:
|
||||||
self.query = ""
|
self.query = ""
|
||||||
self.token = self.form_data.get("token", "token_" + uuid.uuid4().hex[:8])
|
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.time_shift = timedelta()
|
||||||
|
|
||||||
self.status: Optional[str] = None
|
self.status: Optional[str] = None
|
||||||
|
@ -297,8 +298,10 @@ class BaseViz:
|
||||||
def query_obj(self) -> QueryObjectDict:
|
def query_obj(self) -> QueryObjectDict:
|
||||||
"""Building a query object"""
|
"""Building a query object"""
|
||||||
form_data = self.form_data
|
form_data = self.form_data
|
||||||
|
|
||||||
self.process_query_filters()
|
self.process_query_filters()
|
||||||
gb = form_data.get("groupby") or []
|
|
||||||
|
gb = self.groupby
|
||||||
metrics = self.all_metrics or []
|
metrics = self.all_metrics or []
|
||||||
columns = form_data.get("columns") or []
|
columns = form_data.get("columns") or []
|
||||||
groupby = list(set(gb + columns))
|
groupby = list(set(gb + columns))
|
||||||
|
@ -346,7 +349,7 @@ class BaseViz:
|
||||||
"where": form_data.get("where", ""),
|
"where": form_data.get("where", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
d = {
|
return {
|
||||||
"granularity": granularity,
|
"granularity": granularity,
|
||||||
"from_dttm": from_dttm,
|
"from_dttm": from_dttm,
|
||||||
"to_dttm": to_dttm,
|
"to_dttm": to_dttm,
|
||||||
|
@ -360,7 +363,6 @@ class BaseViz:
|
||||||
"timeseries_limit_metric": timeseries_limit_metric,
|
"timeseries_limit_metric": timeseries_limit_metric,
|
||||||
"order_desc": order_desc,
|
"order_desc": order_desc,
|
||||||
}
|
}
|
||||||
return d
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cache_timeout(self) -> int:
|
def cache_timeout(self) -> int:
|
||||||
|
@ -572,6 +574,52 @@ class TableViz(BaseViz):
|
||||||
is_timeseries = False
|
is_timeseries = False
|
||||||
enforce_numerical_metrics = 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:
|
def should_be_timeseries(self) -> bool:
|
||||||
fd = self.form_data
|
fd = self.form_data
|
||||||
# TODO handle datasource-type-specific code in datasource
|
# TODO handle datasource-type-specific code in datasource
|
||||||
|
@ -587,36 +635,24 @@ class TableViz(BaseViz):
|
||||||
def query_obj(self) -> QueryObjectDict:
|
def query_obj(self) -> QueryObjectDict:
|
||||||
d = super().query_obj()
|
d = super().query_obj()
|
||||||
fd = self.form_data
|
fd = self.form_data
|
||||||
|
if self.query_mode == QueryMode.RAW:
|
||||||
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"):
|
|
||||||
d["columns"] = fd.get("all_columns")
|
d["columns"] = fd.get("all_columns")
|
||||||
d["groupby"] = []
|
|
||||||
order_by_cols = fd.get("order_by_cols") or []
|
order_by_cols = fd.get("order_by_cols") or []
|
||||||
d["orderby"] = [json.loads(t) for t in order_by_cols]
|
d["orderby"] = [json.loads(t) for t in order_by_cols]
|
||||||
elif sort_by:
|
# 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)
|
sort_by_label = utils.get_metric_name(sort_by)
|
||||||
if sort_by_label not in utils.get_metric_names(d["metrics"]):
|
if sort_by_label not in d["metrics"]:
|
||||||
d["metrics"] += [sort_by]
|
d["metrics"].append(sort_by)
|
||||||
d["orderby"] = [(sort_by, not fd.get("order_desc", True))]
|
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()
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def get_data(self, df: pd.DataFrame) -> VizData:
|
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 union of the metrics representing the non-percent and percent metrics. Note
|
||||||
the percent metrics have yet to be transformed.
|
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
|
# Transform the data frame to adhere to the UI ordering of the columns and
|
||||||
# metrics whilst simultaneously computing the percentages (via normalization)
|
# metrics whilst simultaneously computing the percentages (via normalization)
|
||||||
# for the percent metrics.
|
# 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:
|
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 = pd.concat(
|
||||||
[
|
[
|
||||||
df[non_percent_metric_columns],
|
df[columns],
|
||||||
(
|
(
|
||||||
df[percent_metric_columns]
|
df[percent_columns]
|
||||||
.div(df[percent_metric_columns].sum())
|
.div(df[percent_columns].sum())
|
||||||
.add_prefix("%")
|
.add_prefix("%")
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
axis=1,
|
axis=1,
|
||||||
)
|
)
|
||||||
|
return self.handle_js_int_overflow(
|
||||||
data = self.handle_js_int_overflow(
|
|
||||||
dict(records=df.to_dict(orient="records"), columns=list(df.columns))
|
dict(records=df.to_dict(orient="records"), columns=list(df.columns))
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def json_dumps(self, obj: Any, sort_keys: bool = False) -> str:
|
def json_dumps(self, obj: Any, sort_keys: bool = False) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
obj, default=utils.json_iso_dttm_ser, sort_keys=sort_keys, ignore_nan=True
|
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("(value3 in ('North America'))", query_obj["extras"]["where"])
|
||||||
self.assertEqual("", query_obj["extras"]["having"])
|
self.assertEqual("", query_obj["extras"]["having"])
|
||||||
|
|
||||||
@patch("superset.viz.BaseViz.query_obj")
|
def test_query_obj_merges_percent_metrics(self):
|
||||||
def test_query_obj_merges_percent_metrics(self, super_query_obj):
|
|
||||||
datasource = self.get_datasource_mock()
|
datasource = self.get_datasource_mock()
|
||||||
form_data = {
|
form_data = {
|
||||||
"percent_metrics": ["sum__A", "avg__B", "max__Y"],
|
|
||||||
"metrics": ["sum__A", "count", "avg__C"],
|
"metrics": ["sum__A", "count", "avg__C"],
|
||||||
|
"percent_metrics": ["sum__A", "avg__B", "max__Y"],
|
||||||
}
|
}
|
||||||
test_viz = viz.TableViz(datasource, form_data)
|
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()
|
query_obj = test_viz.query_obj()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["sum__A", "count", "avg__C", "avg__B", "max__Y"], query_obj["metrics"]
|
["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):
|
||||||
def test_query_obj_throws_columns_and_metrics(self, super_query_obj):
|
|
||||||
datasource = self.get_datasource_mock()
|
datasource = self.get_datasource_mock()
|
||||||
form_data = {"all_columns": ["A", "B"], "metrics": ["x", "y"]}
|
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):
|
with self.assertRaises(Exception):
|
||||||
|
test_viz = viz.TableViz(datasource, form_data)
|
||||||
test_viz.query_obj()
|
test_viz.query_obj()
|
||||||
del form_data["metrics"]
|
del form_data["metrics"]
|
||||||
form_data["groupby"] = ["B", "C"]
|
form_data["groupby"] = ["B", "C"]
|
||||||
test_viz = viz.TableViz(datasource, form_data)
|
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
|
test_viz = viz.TableViz(datasource, form_data)
|
||||||
test_viz.query_obj()
|
test_viz.query_obj()
|
||||||
|
|
||||||
@patch("superset.viz.BaseViz.query_obj")
|
@patch("superset.viz.BaseViz.query_obj")
|
||||||
|
@ -390,21 +385,35 @@ class TableVizTestCase(SupersetTestCase):
|
||||||
self.assertEqual([], query_obj["groupby"])
|
self.assertEqual([], query_obj["groupby"])
|
||||||
self.assertEqual([["colA", "colB"], ["colC"]], query_obj["orderby"])
|
self.assertEqual([["colA", "colB"], ["colC"]], query_obj["orderby"])
|
||||||
|
|
||||||
@patch("superset.viz.BaseViz.query_obj")
|
def test_query_obj_uses_sortby(self):
|
||||||
def test_query_obj_uses_sortby(self, super_query_obj):
|
|
||||||
datasource = self.get_datasource_mock()
|
datasource = self.get_datasource_mock()
|
||||||
form_data = {"timeseries_limit_metric": "__time__", "order_desc": False}
|
form_data = {
|
||||||
super_query_obj.return_value = {"metrics": ["colA", "colB"]}
|
"metrics": ["colA", "colB"],
|
||||||
|
"order_desc": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_test(metric):
|
||||||
|
form_data["timeseries_limit_metric"] = metric
|
||||||
test_viz = viz.TableViz(datasource, form_data)
|
test_viz = viz.TableViz(datasource, form_data)
|
||||||
query_obj = test_viz.query_obj()
|
query_obj = test_viz.query_obj()
|
||||||
self.assertEqual(["colA", "colB", "__time__"], query_obj["metrics"])
|
self.assertEqual(["colA", "colB", metric], query_obj["metrics"])
|
||||||
self.assertEqual([("__time__", True)], query_obj["orderby"])
|
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):
|
def test_should_be_timeseries_raises_when_no_granularity(self):
|
||||||
datasource = self.get_datasource_mock()
|
datasource = self.get_datasource_mock()
|
||||||
form_data = {"include_time": True}
|
form_data = {"include_time": True}
|
||||||
test_viz = viz.TableViz(datasource, form_data)
|
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
|
test_viz = viz.TableViz(datasource, form_data)
|
||||||
test_viz.should_be_timeseries()
|
test_viz.should_be_timeseries()
|
||||||
|
|
||||||
def test_adhoc_metric_with_sortby(self):
|
def test_adhoc_metric_with_sortby(self):
|
||||||
|
|
Loading…
Reference in New Issue