[SIP-4] replace chart ajax calls with `SupersetClient` (#5875)

* [deps] add @superset-ui/core

* [superset-client] initialize SupersetClient in app setup

* [superset-client] add abortcontroller-polyfill

* [superset-client] replace all chart ajax calls with SupersetClient

* [tests] add fetch-mock dep and helpers/setupSupersetClient.js

* [superset client][charts][tests] fix and improve chartActions_spec

* [deps] @superset-ui/core@^0.0.4

* [common] add better SupersetClient initialization error

* [cypress] add readResponseBlob helper, fix broken fetch-based tests

* [cypress] fix tests from rebase

* [deps] @superset-ui/core@^0.0.5

* [cypress][fetch] fix controls test for fetch

* [cypress][dashboard][fetch] fix filter test for fetch

* [superset-client] configure protocol on init

* yarn.lock

* undo Chart.jsx revert

* yarn again

* [superset-client] fix chartAction unit tests
This commit is contained in:
Chris Williams 2018-10-15 16:52:19 -07:00 committed by GitHub
parent 9029701f24
commit 316fdcb4d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 927 additions and 696 deletions

View File

@ -1,4 +1,5 @@
import { WORLD_HEALTH_DASHBOARD, CHECK_DASHBOARD_FAVORITE_ENDPOINT } from './dashboard.helper';
import readResponseBlob from '../../utils/readResponseBlob';
export default () => describe('top-level controls', () => {
let sliceIds = [];
@ -61,8 +62,9 @@ export default () => describe('top-level controls', () => {
cy.wait(forceRefreshRequests).then((xhrs) => {
// is_cached in response should be false
xhrs.forEach((xhr) => {
expect(xhr.response.body.is_cached).to.equal(false);
xhrs.forEach(async (xhr) => {
const responseBody = await readResponseBlob(xhr.response.body);
expect(responseBody.is_cached).to.equal(false);
});
});
});

View File

@ -40,11 +40,11 @@ export default () => describe('dashboard filter', () => {
.type('South Asia{enter}', { force: true });
cy.wait(aliases).then((requests) => {
requests.forEach((request) => {
const requestBody = request.request.body.substring('form_data='.length);
const requestParams = JSON.parse(decodeURIComponent(requestBody));
requests.forEach((xhr) => {
const requestFormData = xhr.request.body;
const requestParams = JSON.parse(requestFormData.get('form_data'));
expect(requestParams.extra_filters[0])
.deep.eq({ col: 'region', op: 'in', val: ['South+Asia'] });
.deep.eq({ col: 'region', op: 'in', val: ['South Asia'] });
});
});
});

View File

@ -1,3 +1,4 @@
import readResponseBlob from '../../utils/readResponseBlob';
import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper';
export default () => describe('load', () => {
@ -24,9 +25,10 @@ export default () => describe('load', () => {
it('should load dashboard', () => {
// wait and verify one-by-one
cy.wait(aliases).then((requests) => {
requests.forEach((xhr) => {
requests.forEach(async (xhr) => {
expect(xhr.status).to.eq(200);
expect(xhr.response.body).to.have.property('error', null);
const responseBody = await readResponseBlob(xhr.response.body);
expect(responseBody).to.have.property('error', null);
cy.get(`#slice-container-${xhr.response.body.form_data.slice_id}`);
});
});

View File

@ -1,4 +1,5 @@
import { FORM_DATA_DEFAULTS, NUM_METRIC } from './shared.helper';
import readResponseBlob from '../../../utils/readResponseBlob';
// Big Number Total
@ -42,10 +43,12 @@ export default () => describe('Big Number Total', () => {
const formData = { ...BIG_NUMBER_DEFAULTS, metric: NUM_METRIC, groupby: ['state'] };
cy.visitChartByParams(JSON.stringify(formData));
cy.wait(['@getJson']).then((data) => {
cy.verifyResponseCodes(data);
cy.wait(['@getJson']).then(async (xhr) => {
cy.verifyResponseCodes(xhr);
cy.verifySliceContainer();
expect(data.response.body.query).not.contains(formData.groupby[0]);
const responseBody = await readResponseBlob(xhr.response.body);
expect(responseBody.query).not.contains(formData.groupby[0]);
});
});
});

View File

@ -1,4 +1,5 @@
import { FORM_DATA_DEFAULTS, NUM_METRIC, SIMPLE_FILTER } from './shared.helper';
import readResponseBlob from '../../../utils/readResponseBlob';
// Table
@ -59,10 +60,11 @@ export default () => describe('Table chart', () => {
cy.visitChartByParams(JSON.stringify(formData));
cy.wait('@getJson').then((data) => {
cy.verifyResponseCodes(data);
cy.wait('@getJson').then(async (xhr) => {
cy.verifyResponseCodes(xhr);
cy.verifySliceContainer('table');
expect(data.response.body.data.records.length).to.eq(limit);
const responseBody = await readResponseBlob(xhr.response.body);
expect(responseBody.data.records.length).to.eq(limit);
});
});
@ -85,10 +87,11 @@ export default () => describe('Table chart', () => {
};
cy.visitChartByParams(JSON.stringify(formData));
cy.wait('@getJson').then((data) => {
cy.verifyResponseCodes(data);
cy.wait('@getJson').then(async (xhr) => {
cy.verifyResponseCodes(xhr);
cy.verifySliceContainer('table');
const records = data.response.body.data.records;
const responseBody = await readResponseBlob(xhr.response.body);
const { records } = responseBody.data;
expect(records[0].num).greaterThan(records[records.length - 1].num);
});
});

View File

@ -24,6 +24,8 @@
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
import readResponseBlob from '../utils/readResponseBlob';
const BASE_EXPLORE_URL = '/superset/explore/?form_data=';
Cypress.Commands.add('login', () => {
@ -50,11 +52,14 @@ Cypress.Commands.add('visitChartByParams', (params) => {
cy.visit(`${BASE_EXPLORE_URL}${params}`);
});
Cypress.Commands.add('verifyResponseCodes', (data) => {
Cypress.Commands.add('verifyResponseCodes', async (xhr) => {
// After a wait response check for valid response
expect(data.status).to.eq(200);
if (data.response.body.error) {
expect(data.response.body.error).to.eq(null);
expect(xhr.status).to.eq(200);
const responseBody = await readResponseBlob(xhr.response.body);
if (responseBody.error) {
expect(responseBody.error).to.eq(null);
}
});
@ -72,11 +77,12 @@ Cypress.Commands.add('verifySliceContainer', (chartSelector) => {
});
Cypress.Commands.add('verifySliceSuccess', ({ waitAlias, querySubstring, chartSelector }) => {
cy.wait(waitAlias).then((data) => {
cy.verifyResponseCodes(data);
cy.wait(waitAlias).then(async (xhr) => {
cy.verifyResponseCodes(xhr);
const responseBody = await readResponseBlob(xhr.response.body);
if (querySubstring) {
expect(data.response.body.query).contains(querySubstring);
expect(responseBody.query).contains(querySubstring);
}
cy.verifySliceContainer(chartSelector);

View File

@ -13,8 +13,11 @@
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
// The following is a workaround for Cypress not supporting fetch.
// By setting window.fetch = null, we force the fetch polyfill to fall back
// to xhr as described here https://github.com/cypress-io/cypress/issues/95
Cypress.on('window:before:load', (win) => {
win.fetch = null; // eslint-disable-line no-param-reassign
});

View File

@ -0,0 +1,11 @@
// This function returns a promise that resolves to the value
// of the passed response blob. It assumes the blob should be read as text,
// and that the response can be parsed as JSON. This is needed to read
// the value of any fetch-based response.
export default function readResponseBlob(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(JSON.parse(reader.result));
reader.readAsText(blob);
});
}

View File

@ -51,9 +51,11 @@
"@data-ui/sparkline": "^0.0.54",
"@data-ui/theme": "^0.0.62",
"@data-ui/xy-chart": "^0.0.61",
"@superset-ui/core": "^0.0.5",
"@vx/legend": "^0.0.170",
"@vx/responsive": "0.0.172",
"@vx/scale": "^0.0.165",
"abortcontroller-polyfill": "^1.1.9",
"babel-register": "^6.24.1",
"bootstrap": "^3.3.6",
"bootstrap-slider": "^10.0.0",
@ -158,6 +160,7 @@
"eslint-plugin-prettier": "^2.6.0",
"eslint-plugin-react": "^7.0.1",
"exports-loader": "^0.7.0",
"fetch-mock": "^7.0.0-alpha.6",
"file-loader": "^1.1.11",
"gl": "^4.0.4",
"ignore-styles": "^5.0.1",

View File

@ -0,0 +1,10 @@
import fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
export default function setupSupersetClient() {
// The following is needed to mock out SupersetClient requests
// including CSRF authentication and initialization
global.FormData = window.FormData; // used by SupersetClient
fetchMock.get('glob:*superset/csrf_token/*', { csrf_token: '1234' });
SupersetClient.configure({ protocol: 'http', host: 'localhost' }).init();
}

View File

@ -1,5 +1,6 @@
/* eslint no-native-reassign: 0 */
import 'babel-polyfill';
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import jsdom from 'jsdom';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

View File

@ -1,37 +1,123 @@
import fetchMock from 'fetch-mock';
import sinon from 'sinon';
import $ from 'jquery';
import { Logger } from '../../../src/logger';
import setupSupersetClient from '../../helpers/setupSupersetClient';
import * as exploreUtils from '../../../src/explore/exploreUtils';
import * as actions from '../../../src/chart/chartAction';
describe('chart actions', () => {
const MOCK_URL = '/mockURL';
let dispatch;
let urlStub;
let ajaxStub;
let request;
let loggerStub;
const setupDefaultFetchMock = () => {
fetchMock.post(MOCK_URL, { json: {} }, { overwriteRoutes: true });
};
beforeAll(() => {
setupSupersetClient();
setupDefaultFetchMock();
});
afterAll(fetchMock.restore);
beforeEach(() => {
dispatch = sinon.spy();
urlStub = sinon.stub(exploreUtils, 'getExploreUrlAndPayload')
.callsFake(() => ({ url: 'mockURL', payload: {} }));
ajaxStub = sinon.stub($, 'ajax');
urlStub = sinon
.stub(exploreUtils, 'getExploreUrlAndPayload')
.callsFake(() => ({ url: MOCK_URL, payload: {} }));
loggerStub = sinon.stub(Logger, 'append');
});
afterEach(() => {
urlStub.restore();
ajaxStub.restore();
loggerStub.restore();
fetchMock.resetHistory();
});
it('should handle query timeout', () => {
ajaxStub.rejects({ statusText: 'timeout' });
request = actions.runQuery({});
const promise = request(dispatch, sinon.stub().returns({
explore: {
controls: [],
},
}));
promise.then(() => {
expect(dispatch.callCount).toBe(3);
expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_TIMEOUT);
it('should dispatch CHART_UPDATE_STARTED action before the query', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_STARTED);
return Promise.resolve();
});
});
it('should dispatch TRIGGER_QUERY action with the query', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[1][0].type).toBe(actions.TRIGGER_QUERY);
return Promise.resolve();
});
});
it('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[2][0].type).toBe(actions.UPDATE_QUERY_FORM_DATA);
return Promise.resolve();
});
});
it('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED);
return Promise.resolve();
});
});
it('should CHART_UPDATE_TIMEOUT action upon query timeout', () => {
const unresolvingPromise = new Promise(() => {});
fetchMock.post(MOCK_URL, () => unresolvingPromise, { overwriteRoutes: true });
const timeoutInSec = 1 / 1000;
const actionThunk = actions.runQuery({}, false, timeoutInSec);
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(4);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_TIMEOUT);
setupDefaultFetchMock();
return Promise.resolve();
});
});
it('should dispatch CHART_UPDATE_FAILED action upon non-timeout non-abort failure', () => {
fetchMock.post(MOCK_URL, { throws: { error: 'misc error' } }, { overwriteRoutes: true });
const timeoutInSec = 1 / 1000;
const actionThunk = actions.runQuery({}, false, timeoutInSec);
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(4);
const updateFailedAction = dispatch.args[3][0];
expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED);
expect(updateFailedAction.queryResponse.error).toBe('misc error');
setupDefaultFetchMock();
return Promise.resolve();
});
});
});

View File

@ -31,7 +31,6 @@ const propTypes = {
chartUpdateEndTime: PropTypes.number,
chartUpdateStartTime: PropTypes.number,
latestQueryFormData: PropTypes.object,
queryRequest: PropTypes.object,
queryResponse: PropTypes.object,
lastRendered: PropTypes.number,
triggerQuery: PropTypes.bool,

View File

@ -1,16 +1,16 @@
import URI from 'urijs';
/* global window, AbortController */
/* eslint no-undef: 'error' */
import { SupersetClient } from '@superset-ui/core';
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
import { addDangerToast } from '../messageToasts/actions';
import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
import { COMMON_ERR_MESSAGES } from '../utils/common';
import { t } from '../locales';
const $ = (window.$ = require('jquery'));
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
return { type: CHART_UPDATE_STARTED, queryRequest, latestQueryFormData, key };
export function chartUpdateStarted(queryController, latestQueryFormData, key) {
return { type: CHART_UPDATE_STARTED, queryController, latestQueryFormData, key };
}
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
@ -54,8 +54,8 @@ export function annotationQuerySuccess(annotation, queryResponse, key) {
}
export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED';
export function annotationQueryStarted(annotation, queryRequest, key) {
return { type: ANNOTATION_QUERY_STARTED, annotation, queryRequest, key };
export function annotationQueryStarted(annotation, queryController, key) {
return { type: ANNOTATION_QUERY_STARTED, annotation, queryController, key };
}
export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED';
@ -85,18 +85,21 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke
);
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
const queryRequest = $.ajax({
const controller = new AbortController();
const { signal } = controller;
dispatch(annotationQueryStarted(annotation, controller, sliceKey));
return SupersetClient.get({
url,
dataType: 'json',
signal,
timeout: timeout * 1000,
});
dispatch(annotationQueryStarted(annotation, queryRequest, sliceKey));
return queryRequest
.then(queryResponse => dispatch(annotationQuerySuccess(annotation, queryResponse, sliceKey)))
})
.then(({ json }) => dispatch(annotationQuerySuccess(annotation, json, sliceKey)))
.catch((err) => {
if (err.statusText === 'timeout') {
dispatch(annotationQueryFailed(annotation, { error: 'Query Timeout' }, sliceKey));
} else if ((err.responseJSON.error || '').toLowerCase().startsWith('no data')) {
} else if ((err.responseJSON.error || '').toLowerCase().includes('no data')) {
dispatch(annotationQuerySuccess(annotation, err, sliceKey));
} else if (err.statusText !== 'abort') {
dispatch(annotationQueryFailed(annotation, err.responseJSON, sliceKey));
@ -135,30 +138,30 @@ export function runQuery(formData, force = false, timeout = 60, key) {
force,
});
const logStart = Logger.getTimestamp();
const queryRequest = $.ajax({
type: 'POST',
const controller = new AbortController();
const { signal } = controller;
dispatch(chartUpdateStarted(controller, payload, key));
const queryPromise = SupersetClient.post({
url,
dataType: 'json',
data: {
form_data: JSON.stringify(payload),
},
postPayload: { form_data: payload },
signal,
timeout: timeout * 1000,
});
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
.then(() => queryRequest)
.then((queryResponse) => {
})
.then(({ json }) => {
Logger.append(LOG_ACTIONS_LOAD_CHART, {
slice_id: key,
is_cached: queryResponse.is_cached,
is_cached: json.is_cached,
force_refresh: force,
row_count: queryResponse.rowcount,
row_count: json.rowcount,
datasource: formData.datasource,
start_offset: logStart,
duration: Logger.getTimestamp() - logStart,
has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
viz_type: formData.viz_type,
});
return dispatch(chartUpdateSucceeded(queryResponse, key));
return dispatch(chartUpdateSucceeded(json, key));
})
.catch((err) => {
Logger.append(LOG_ACTIONS_LOAD_CHART, {
@ -170,30 +173,30 @@ export function runQuery(formData, force = false, timeout = 60, key) {
});
if (err.statusText === 'timeout') {
dispatch(chartUpdateTimeout(err.statusText, timeout, key));
} else if (err.statusText === 'abort') {
} else if (err.statusText === 'AbortError') {
dispatch(chartUpdateStopped(key));
} else {
let errObject;
let errObject = err;
if (err.responseJSON) {
errObject = err.responseJSON;
} else if (err.stack) {
errObject = {
error: t('Unexpected error: ') + err.description,
error:
t('Unexpected error: ') +
(err.description || t('(no description, click to see stack trace)')),
stacktrace: err.stack,
};
} else if (err.responseText && err.responseText.indexOf('CSRF') >= 0) {
errObject = {
error: COMMON_ERR_MESSAGES.SESSION_TIMED_OUT,
};
} else {
errObject = {
error: t('Unexpected error.'),
};
}
dispatch(chartUpdateFailed(errObject, key));
}
});
const annotationLayers = formData.annotation_layers || [];
return Promise.all([
queryPromise,
dispatch(triggerQuery(false, key)),
@ -203,29 +206,21 @@ export function runQuery(formData, force = false, timeout = 60, key) {
};
}
export const SQLLAB_REDIRECT_FAILED = 'SQLLAB_REDIRECT_FAILED';
export function sqllabRedirectFailed(error, key) {
return { type: SQLLAB_REDIRECT_FAILED, error, key };
}
export function redirectSQLLab(formData) {
return function (dispatch) {
const { url, payload } = getExploreUrlAndPayload({ formData, endpointType: 'query' });
$.ajax({
type: 'POST',
url,
data: {
form_data: JSON.stringify(payload),
},
success: (response) => {
const redirectUrl = new URI(window.location);
redirectUrl
.pathname('/superset/sqllab')
.search({ datasourceKey: formData.datasource, sql: response.query });
window.open(redirectUrl.href(), '_blank');
},
error: (xhr, status, error) => dispatch(sqllabRedirectFailed(error, formData.slice_id)),
});
return (dispatch) => {
const { url } = getExploreUrlAndPayload({ formData, endpointType: 'query' });
return SupersetClient.get({ url })
.then(({ json }) => {
const redirectUrl = new URL(window.location);
redirectUrl.pathname = '/superset/sqllab';
for (const key of redirectUrl.searchParams.keys()) {
redirectUrl.searchParams.delete(key);
}
redirectUrl.searchParams.set('datasourceKey', formData.datasource);
redirectUrl.searchParams.set('sql', json.query);
window.open(redirectUrl.href, '_blank');
})
.catch(() => dispatch(addDangerToast(t('An error occurred while loading the SQL'))));
};
}

View File

@ -1,14 +1,14 @@
/* eslint-disable global-require */
/* eslint global-require: 0, no-console: 0 */
import $ from 'jquery';
import { SupersetClient } from '@superset-ui/core';
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only';
import airbnb from './modules/colorSchemes/airbnb';
import categoricalSchemes from './modules/colorSchemes/categorical';
import lyft from './modules/colorSchemes/lyft';
import { getInstance } from './modules/ColorSchemeManager';
import { toggleCheckbox } from './modules/utils';
// Everything imported in this file ends up in the common entry file
// be mindful of double-imports
$(document).ready(function () {
$(':checkbox[data-checkbox-api-prefix]').change(function () {
const $this = $(this);
@ -22,10 +22,9 @@ $(document).ready(function () {
ev.preventDefault();
const targetUrl = ev.currentTarget.href;
$.ajax(targetUrl)
.then(() => {
location.reload();
});
$.ajax(targetUrl).then(() => {
location.reload();
});
});
});
@ -37,9 +36,18 @@ getInstance()
.setDefaultSchemeName('bnbColors');
export function appSetup() {
// A set of hacks to allow apps to run within a FAB template
// A set of hacks to allow apps to run within a FAB template
// this allows for the server side generated menus to function
window.$ = $;
window.jQuery = $;
require('bootstrap');
SupersetClient.configure({
protocol: (window.location && window.location.protocol) || '',
host: (window.location && window.location.host) || '',
})
.init()
.catch((error) => {
console.warn('Error initializing SupersetClient', error);
});
}

View File

@ -62,7 +62,7 @@ class ExploreChartPanel extends React.PureComponent {
latestQueryFormData={chart.latestQueryFormData}
lastRendered={chart.lastRendered}
queryResponse={chart.queryResponse}
queryRequest={chart.queryRequest}
queryController={chart.queryController}
triggerQuery={chart.triggerQuery}
/>
);

View File

@ -54,6 +54,9 @@ class ExploreViewContainer extends React.Component {
this.addHistory = this.addHistory.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handlePopstate = this.handlePopstate.bind(this);
this.onStop = this.onStop.bind(this);
this.onQuery = this.onQuery.bind(this);
this.toggleModal = this.toggleModal.bind(this);
}
componentDidMount() {
@ -124,7 +127,9 @@ class ExploreViewContainer extends React.Component {
}
onStop() {
return this.props.chart.queryRequest.abort();
if (this.props.chart && this.props.chart.queryController) {
this.props.chart.queryController.abort();
}
}
getWidth() {
@ -262,7 +267,7 @@ class ExploreViewContainer extends React.Component {
>
{this.state.showModal && (
<SaveModal
onHide={this.toggleModal.bind(this)}
onHide={this.toggleModal}
actions={this.props.actions}
form_data={this.props.form_data}
/>
@ -271,9 +276,9 @@ class ExploreViewContainer extends React.Component {
<div className="col-sm-4">
<QueryAndSaveBtns
canAdd="True"
onQuery={this.onQuery.bind(this)}
onSave={this.toggleModal.bind(this)}
onStop={this.onStop.bind(this)}
onQuery={this.onQuery}
onSave={this.toggleModal}
onStop={this.onStop}
loading={this.props.chart.chartStatus === 'loading'}
chartIsStale={this.state.chartIsStale}
errorMessage={this.renderErrorMessage()}

View File

@ -2,10 +2,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import { CompactPicker } from 'react-color';
import { Button } from 'react-bootstrap';
import $ from 'jquery';
import mathjs from 'mathjs';
import { SupersetClient } from '@superset-ui/core';
import SelectControl from './SelectControl';
import TextControl from './TextControl';
import CheckboxControl from './CheckboxControl';
@ -83,10 +82,24 @@ const defaultProps = {
export default class AnnotationLayer extends React.PureComponent {
constructor(props) {
super(props);
const { name, annotationType, sourceType,
color, opacity, style, width, showMarkers, hideLine, value,
overrides, show, titleColumn, descriptionColumns,
timeColumn, intervalEndColumn } = props;
const {
name,
annotationType,
sourceType,
color,
opacity,
style,
width,
showMarkers,
hideLine,
value,
overrides,
show,
titleColumn,
descriptionColumns,
timeColumn,
intervalEndColumn,
} = props;
this.state = {
// base
name,
@ -119,8 +132,7 @@ export default class AnnotationLayer extends React.PureComponent {
this.applyAnnotation = this.applyAnnotation.bind(this);
this.fetchOptions = this.fetchOptions.bind(this);
this.handleAnnotationType = this.handleAnnotationType.bind(this);
this.handleAnnotationSourceType =
this.handleAnnotationSourceType.bind(this);
this.handleAnnotationSourceType = this.handleAnnotationSourceType.bind(this);
this.handleValue = this.handleValue.bind(this);
this.isValidForm = this.isValidForm.bind(this);
}
@ -139,7 +151,10 @@ export default class AnnotationLayer extends React.PureComponent {
isValidFormula(value, annotationType) {
if (annotationType === AnnotationTypes.FORMULA) {
try {
mathjs.parse(value).compile().eval({ x: 0 });
mathjs
.parse(value)
.compile()
.eval({ x: 0 });
} catch (err) {
return true;
}
@ -148,10 +163,7 @@ export default class AnnotationLayer extends React.PureComponent {
}
isValidForm() {
const {
name, annotationType, sourceType,
value, timeColumn, intervalEndColumn,
} = this.state;
const { name, annotationType, sourceType, value, timeColumn, intervalEndColumn } = this.state;
const errors = [nonEmpty(name), nonEmpty(annotationType), nonEmpty(value)];
if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) {
if (annotationType === AnnotationTypes.EVENT) {
@ -166,7 +178,6 @@ export default class AnnotationLayer extends React.PureComponent {
return !errors.filter(x => x).length;
}
handleAnnotationType(annotationType) {
this.setState({
annotationType,
@ -199,31 +210,25 @@ export default class AnnotationLayer extends React.PureComponent {
fetchOptions(annotationType, sourceType, isLoadingOptions) {
if (isLoadingOptions === true) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
$.ajax({
type: 'GET',
url: '/annotationlayermodelview/api/read?',
}).then((data) => {
const layers = data ? data.result.map(layer => ({
value: layer.id,
label: layer.name,
})) : [];
SupersetClient.get({ endpoint: '/annotationlayermodelview/api/read?' }).then(({ json }) => {
const layers = json
? json.result.map(layer => ({
value: layer.id,
label: layer.name,
}))
: [];
this.setState({
isLoadingOptions: false,
valueOptions: layers,
});
});
} else if (requiresQuery(sourceType)) {
$.ajax({
type: 'GET',
url: '/superset/user_slices',
}).then(data =>
SupersetClient.get({ endpoint: '/superset/user_slices' }).then(({ json }) =>
this.setState({
isLoadingOptions: false,
valueOptions: data.filter(
x => getSupportedSourceTypes(annotationType)
.find(v => v === x.viz_type))
.map(x => ({ value: x.id, label: x.title, slice: x }),
),
valueOptions: json
.filter(x => getSupportedSourceTypes(annotationType).find(v => v === x.viz_type))
.map(x => ({ value: x.id, label: x.title, slice: x })),
}),
);
} else {
@ -266,26 +271,26 @@ export default class AnnotationLayer extends React.PureComponent {
}
renderValueConfiguration() {
const { annotationType, sourceType, value,
valueOptions, isLoadingOptions } = this.state;
const { annotationType, sourceType, value, valueOptions, isLoadingOptions } = this.state;
let label = '';
let description = '';
if (requiresQuery(sourceType)) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
label = t('Annotation Layer');
description = t('Select the Annotation Layer you would like to use.');
label = 'Annotation Layer';
description = 'Select the Annotation Layer you would like to use.';
} else {
label = t('Chart');
label = label = t('Chart');
description = `Use a pre defined Superset Chart as a source for annotations and overlays.
'your chart must be one of these visualization types:
'[${getSupportedSourceTypes(annotationType)
.map(x => ((x in vizTypes && 'label' in vizTypes[x]) ? vizTypes[x].label : '')).join(', ')}]'`;
your chart must be one of these visualization types:
[${getSupportedSourceTypes(annotationType)
.map(x => (x in vizTypes && 'label' in vizTypes[x] ? vizTypes[x].label : ''))
.join(', ')}]`;
}
} else if (annotationType === AnnotationTypes.FORMULA) {
label = t('Formula');
description = t(`Expects a formula with depending time parameter 'x'
label = 'Formula';
description = `Expects a formula with depending time parameter 'x'
in milliseconds since epoch. mathjs is used to evaluate the formulas.
Example: '2x+5'`);
Example: '2x+5'`;
}
if (requiresQuery(sourceType)) {
return (
@ -300,10 +305,11 @@ export default class AnnotationLayer extends React.PureComponent {
isLoading={isLoadingOptions}
value={value}
onChange={this.handleValue}
validationErrors={!value ? [t('Mandatory')] : []}
validationErrors={!value ? ['Mandatory'] : []}
/>
);
} if (annotationType === AnnotationTypes.FORMULA) {
}
if (annotationType === AnnotationTypes.FORMULA) {
return (
<TextControl
name="annotation-layer-value"
@ -314,7 +320,7 @@ export default class AnnotationLayer extends React.PureComponent {
placeholder=""
value={value}
onChange={this.handleValue}
validationErrors={this.isValidFormula(value, annotationType) ? [t('Bad formula.')] : []}
validationErrors={this.isValidFormula(value, annotationType) ? ['Bad formula.'] : []}
/>
);
}
@ -322,37 +328,43 @@ export default class AnnotationLayer extends React.PureComponent {
}
renderSliceConfiguration() {
const { annotationType, sourceType, value, valueOptions, overrides, titleColumn,
timeColumn, intervalEndColumn, descriptionColumns } = this.state;
const {
annotationType,
sourceType,
value,
valueOptions,
overrides,
titleColumn,
timeColumn,
intervalEndColumn,
descriptionColumns,
} = this.state;
const slice = (valueOptions.find(x => x.value === value) || {}).slice;
if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && slice) {
const columns = (slice.data.groupby || []).concat(
(slice.data.all_columns || [])).map(x => ({ value: x, label: x }));
const timeColumnOptions = slice.data.include_time ?
[{ value: '__timestamp', label: '__timestamp' }].concat(columns) : columns;
const columns = (slice.data.groupby || [])
.concat(slice.data.all_columns || [])
.map(x => ({ value: x, label: x }));
const timeColumnOptions = slice.data.include_time
? [{ value: '__timestamp', label: '__timestamp' }].concat(columns)
: columns;
return (
<div style={{ marginRight: '2rem' }}>
<PopoverSection
isSelected
onSelect={() => {
}}
onSelect={() => {}}
title="Annotation Slice Configuration"
info={
`This section allows you to configure how to use the slice
to generate annotations.`
}
info={`This section allows you to configure how to use the slice
to generate annotations.`}
>
{
(
annotationType === AnnotationTypes.EVENT ||
annotationType === AnnotationTypes.INTERVAL
) &&
{(annotationType === AnnotationTypes.EVENT ||
annotationType === AnnotationTypes.INTERVAL) && (
<SelectControl
hovered
name="annotation-layer-time-column"
label={
annotationType === AnnotationTypes.INTERVAL ?
'Interval Start column' : 'Event Time Column'
annotationType === AnnotationTypes.INTERVAL
? 'Interval Start column'
: 'Event Time Column'
}
description={'This column must contain date/time information.'}
validationErrors={!timeColumn ? ['Mandatory'] : []}
@ -361,9 +373,8 @@ export default class AnnotationLayer extends React.PureComponent {
value={timeColumn}
onChange={v => this.setState({ timeColumn: v })}
/>
}
{
annotationType === AnnotationTypes.INTERVAL &&
)}
{annotationType === AnnotationTypes.INTERVAL && (
<SelectControl
hovered
name="annotation-layer-intervalEnd"
@ -374,20 +385,17 @@ export default class AnnotationLayer extends React.PureComponent {
value={intervalEndColumn}
onChange={v => this.setState({ intervalEndColumn: v })}
/>
}
)}
<SelectControl
hovered
name="annotation-layer-title"
label="Title Column"
description={'Pick a title for you annotation.'}
options={
[{ value: '', label: 'None' }].concat(columns)
}
options={[{ value: '', label: 'None' }].concat(columns)}
value={titleColumn}
onChange={v => this.setState({ titleColumn: v })}
/>
{
annotationType !== AnnotationTypes.TIME_SERIES &&
{annotationType !== AnnotationTypes.TIME_SERIES && (
<SelectControl
hovered
name="annotation-layer-title"
@ -395,13 +403,11 @@ export default class AnnotationLayer extends React.PureComponent {
description={`Pick one or more columns that should be shown in the
annotation. If you don't select a column all of them will be shown.`}
multi
options={
columns
}
options={columns}
value={descriptionColumns}
onChange={v => this.setState({ descriptionColumns: v })}
/>
}
)}
<div style={{ marginTop: '1rem' }}>
<CheckboxControl
hovered
@ -473,14 +479,17 @@ export default class AnnotationLayer extends React.PureComponent {
</div>
);
}
return ('');
return '';
}
renderDisplayConfiguration() {
const { color, opacity, style, width, showMarkers, hideLine, annotationType } = this.state;
const colorScheme = [...getScheme(this.props.colorScheme)];
if (color && color !== AUTOMATIC_COLOR &&
!colorScheme.find(x => x.toLowerCase() === color.toLowerCase())) {
if (
color &&
color !== AUTOMATIC_COLOR &&
!colorScheme.find(x => x.toLowerCase() === color.toLowerCase())
) {
colorScheme.push(color);
}
return (
@ -493,12 +502,12 @@ export default class AnnotationLayer extends React.PureComponent {
<SelectControl
name="annotation-layer-stroke"
label={t('Style')}
// see '../../../visualizations/nvd3_vis.css'
// see '../../../visualizations/nvd3_vis.css'
options={[
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'longDashed', label: 'Long Dashed' },
{ value: 'dotted', label: 'Dotted' },
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'longDashed', label: 'Long Dashed' },
{ value: 'dotted', label: 'Dotted' },
]}
value={style}
onChange={v => this.setState({ style: v })}
@ -506,12 +515,12 @@ export default class AnnotationLayer extends React.PureComponent {
<SelectControl
name="annotation-layer-opacity"
label={t('Opacity')}
// see '../../../visualizations/nvd3_vis.css'
// see '../../../visualizations/nvd3_vis.css'
options={[
{ value: '', label: 'Solid' },
{ value: 'opacityLow', label: '0.2' },
{ value: 'opacityMedium', label: '0.5' },
{ value: 'opacityHigh', label: '0.8' },
{ value: '', label: 'Solid' },
{ value: 'opacityLow', label: '0.2' },
{ value: 'opacityMedium', label: '0.5' },
{ value: 'opacityHigh', label: '0.8' },
]}
value={opacity}
onChange={v => this.setState({ opacity: v })}
@ -530,7 +539,7 @@ export default class AnnotationLayer extends React.PureComponent {
bsSize="xsmall"
onClick={() => this.setState({ color: AUTOMATIC_COLOR })}
>
{t('Automatic Color')}
Automatic Color
</Button>
</div>
</div>
@ -541,42 +550,36 @@ export default class AnnotationLayer extends React.PureComponent {
value={width}
onChange={v => this.setState({ width: v })}
/>
{annotationType === AnnotationTypes.TIME_SERIES &&
<CheckboxControl
hovered
name="annotation-layer-show-markers"
label={t('Show Markers')}
description={t('Shows or hides markers for the time series')}
value={showMarkers}
onChange={v => this.setState({ showMarkers: v })}
/>
}
{annotationType === AnnotationTypes.TIME_SERIES &&
<CheckboxControl
hovered
name="annotation-layer-hide-line"
label={t('Hide Line')}
description={t('Hides the Line for the time series')}
value={hideLine}
onChange={v => this.setState({ hideLine: v })}
/>
}
{annotationType === AnnotationTypes.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-show-markers"
label="Show Markers"
description={'Shows or hides markers for the time series'}
value={showMarkers}
onChange={v => this.setState({ showMarkers: v })}
/>
)}
{annotationType === AnnotationTypes.TIME_SERIES && (
<CheckboxControl
hovered
name="annotation-layer-hide-line"
label="Hide Line"
description={'Hides the Line for the time series'}
value={hideLine}
onChange={v => this.setState({ hideLine: v })}
/>
)}
</PopoverSection>
);
}
render() {
const { isNew, name, annotationType,
sourceType, show } = this.state;
const { isNew, name, annotationType, sourceType, show } = this.state;
const isValid = this.isValidForm();
return (
<div>
{
this.props.error &&
<span style={{ color: 'red' }}>
ERROR: {this.props.error}
</span>
}
{this.props.error && <span style={{ color: 'red' }}>ERROR: {this.props.error}</span>}
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ marginRight: '2rem' }}>
<PopoverSection
@ -604,50 +607,43 @@ export default class AnnotationLayer extends React.PureComponent {
description={t('Choose the Annotation Layer Type')}
label={t('Annotation Layer Type')}
name="annotation-layer-type"
options={getSupportedAnnotationTypes(this.props.vizType).map(
x => ({ value: x, label: getAnnotationTypeLabel(x) }))}
options={getSupportedAnnotationTypes(this.props.vizType).map(x => ({
value: x,
label: getAnnotationTypeLabel(x),
}))}
value={annotationType}
onChange={this.handleAnnotationType}
/>
{!!getSupportedSourceTypes(annotationType).length &&
{!!getSupportedSourceTypes(annotationType).length && (
<SelectControl
hovered
description={t('Choose the source of your annotations')}
label={t('Annotation Source')}
description="Choose the source of your annotations"
label="Annotation Source"
name="annotation-source-type"
options={getSupportedSourceTypes(annotationType).map(
x => ({ value: x, label: getAnnotationSourceTypeLabels(x) }))}
options={getSupportedSourceTypes(annotationType).map(x => ({
value: x,
label: getAnnotationSourceTypeLabels(x),
}))}
value={sourceType}
onChange={this.handleAnnotationSourceType}
/>
}
{ this.renderValueConfiguration() }
)}
{this.renderValueConfiguration()}
</PopoverSection>
</div>
{ this.renderSliceConfiguration() }
{ this.renderDisplayConfiguration() }
{this.renderSliceConfiguration()}
{this.renderDisplayConfiguration()}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
bsSize="sm"
onClick={this.deleteAnnotation}
>
{ !isNew ? t('Remove') : t('Cancel') }
<Button bsSize="sm" onClick={this.deleteAnnotation}>
{!isNew ? t('Remove') : t('Cancel')}
</Button>
<div>
<Button
bsSize="sm"
disabled={!isValid}
onClick={this.applyAnnotation}
>
<Button bsSize="sm" disabled={!isValid} onClick={this.applyAnnotation}>
{t('Apply')}
</Button>
<Button
bsSize="sm"
disabled={!isValid}
onClick={this.submitAnnotation}
>
<Button bsSize="sm" disabled={!isValid} onClick={this.submitAnnotation}>
{t('OK')}
</Button>
</div>
@ -656,5 +652,6 @@ export default class AnnotationLayer extends React.PureComponent {
);
}
}
AnnotationLayer.propTypes = propTypes;
AnnotationLayer.defaultProps = defaultProps;

View File

@ -5,9 +5,10 @@ import { now } from '../../modules/dates';
import { getChartKey } from '../exploreUtils';
import { getControlsState, getFormDataFromControls } from '../store';
export default function (bootstrapData) {
export default function getInitialState(bootstrapData) {
const controls = getControlsState(bootstrapData, bootstrapData.form_data);
const rawFormData = { ...bootstrapData.form_data };
const bootstrappedState = {
...bootstrapData,
common: {
@ -20,11 +21,15 @@ export default function (bootstrapData) {
isDatasourceMetaLoading: false,
isStarred: false,
};
const slice = bootstrappedState.slice;
const sliceFormData = slice
? getFormDataFromControls(getControlsState(bootstrapData, slice.form_data))
: null;
const chartKey = getChartKey(bootstrappedState);
return {
featureFlags: bootstrapData.common.feature_flags,
charts: {
@ -36,7 +41,7 @@ export default function (bootstrapData) {
chartUpdateStartTime: now(),
latestQueryFormData: getFormDataFromControls(controls),
sliceFormData,
queryRequest: null,
queryController: null,
queryResponse: null,
triggerQuery: true,
lastRendered: 0,

File diff suppressed because it is too large Load Diff