mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
[clarity/consistency] rename /explorev2/ -> /explore/ (#2802)
* rename /explorev2/ -> /explore/ * add redirect for existing explorev2 urls * fix long line * remove extra line * fix missed ref in spec
This commit is contained in:
265
superset/assets/javascripts/explore/actions/exploreActions.js
Normal file
265
superset/assets/javascripts/explore/actions/exploreActions.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { QUERY_TIMEOUT_THRESHOLD } from '../../constants';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
|
||||
|
||||
export const SET_DATASOURCE_TYPE = 'SET_DATASOURCE_TYPE';
|
||||
export function setDatasourceType(datasourceType) {
|
||||
return { type: SET_DATASOURCE_TYPE, datasourceType };
|
||||
}
|
||||
|
||||
export const SET_DATASOURCE = 'SET_DATASOURCE';
|
||||
export function setDatasource(datasource) {
|
||||
return { type: SET_DATASOURCE, datasource };
|
||||
}
|
||||
|
||||
export const SET_DATASOURCES = 'SET_DATASOURCES';
|
||||
export function setDatasources(datasources) {
|
||||
return { type: SET_DATASOURCES, datasources };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
|
||||
export function fetchDatasourceStarted() {
|
||||
return { type: FETCH_DATASOURCE_STARTED };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED';
|
||||
export function fetchDatasourceSucceeded() {
|
||||
return { type: FETCH_DATASOURCE_SUCCEEDED };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
|
||||
export function fetchDatasourceFailed(error) {
|
||||
return { type: FETCH_DATASOURCE_FAILED, error };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED';
|
||||
export function fetchDatasourcesStarted() {
|
||||
return { type: FETCH_DATASOURCES_STARTED };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCES_SUCCEEDED = 'FETCH_DATASOURCES_SUCCEEDED';
|
||||
export function fetchDatasourcesSucceeded() {
|
||||
return { type: FETCH_DATASOURCES_SUCCEEDED };
|
||||
}
|
||||
|
||||
export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED';
|
||||
export function fetchDatasourcesFailed(error) {
|
||||
return { type: FETCH_DATASOURCES_FAILED, error };
|
||||
}
|
||||
|
||||
export const RESET_FIELDS = 'RESET_FIELDS';
|
||||
export function resetControls() {
|
||||
return { type: RESET_FIELDS };
|
||||
}
|
||||
|
||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
||||
export function triggerQuery() {
|
||||
return { type: TRIGGER_QUERY };
|
||||
}
|
||||
|
||||
export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) {
|
||||
return function (dispatch) {
|
||||
dispatch(fetchDatasourceStarted());
|
||||
const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`;
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url,
|
||||
success: (data) => {
|
||||
dispatch(setDatasource(data));
|
||||
dispatch(fetchDatasourceSucceeded());
|
||||
dispatch(resetControls());
|
||||
if (alsoTriggerQuery) {
|
||||
dispatch(triggerQuery());
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
dispatch(fetchDatasourceFailed(error.responseJSON.error));
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchDatasources() {
|
||||
return function (dispatch) {
|
||||
dispatch(fetchDatasourcesStarted());
|
||||
const url = '/superset/datasources/';
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url,
|
||||
success: (data) => {
|
||||
dispatch(setDatasources(data));
|
||||
dispatch(fetchDatasourcesSucceeded());
|
||||
},
|
||||
error(error) {
|
||||
dispatch(fetchDatasourcesFailed(error.responseJSON.error));
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
|
||||
export function toggleFaveStar(isStarred) {
|
||||
return { type: TOGGLE_FAVE_STAR, isStarred };
|
||||
}
|
||||
|
||||
export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
|
||||
export function fetchFaveStar(sliceId) {
|
||||
return function (dispatch) {
|
||||
const url = `${FAVESTAR_BASE_URL}/${sliceId}/count`;
|
||||
$.get(url, (data) => {
|
||||
if (data.count > 0) {
|
||||
dispatch(toggleFaveStar(true));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
|
||||
export function saveFaveStar(sliceId, isStarred) {
|
||||
return function (dispatch) {
|
||||
const urlSuffix = isStarred ? 'unselect' : 'select';
|
||||
const url = `${FAVESTAR_BASE_URL}/${sliceId}/${urlSuffix}/`;
|
||||
$.get(url);
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_FIELD_VALUE = 'SET_FIELD_VALUE';
|
||||
export function setControlValue(controlName, value, validationErrors) {
|
||||
return { type: SET_FIELD_VALUE, controlName, value, validationErrors };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted(queryRequest) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(queryRequest) {
|
||||
if (queryRequest) {
|
||||
queryRequest.abort();
|
||||
}
|
||||
return { type: CHART_UPDATE_STOPPED };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
|
||||
export function chartUpdateTimeout(statusText) {
|
||||
return { type: CHART_UPDATE_TIMEOUT, statusText };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
||||
export function chartRenderingFailed(error) {
|
||||
return { type: CHART_RENDERING_FAILED, error };
|
||||
}
|
||||
|
||||
export const UPDATE_EXPLORE_ENDPOINTS = 'UPDATE_EXPLORE_ENDPOINTS';
|
||||
export function updateExploreEndpoints(jsonUrl, csvUrl, standaloneUrl) {
|
||||
return { type: UPDATE_EXPLORE_ENDPOINTS, jsonUrl, csvUrl, standaloneUrl };
|
||||
}
|
||||
|
||||
export const REMOVE_CONTROL_PANEL_ALERT = 'REMOVE_CONTROL_PANEL_ALERT';
|
||||
export function removeControlPanelAlert() {
|
||||
return { type: REMOVE_CONTROL_PANEL_ALERT };
|
||||
}
|
||||
|
||||
export const REMOVE_CHART_ALERT = 'REMOVE_CHART_ALERT';
|
||||
export function removeChartAlert() {
|
||||
return { type: REMOVE_CHART_ALERT };
|
||||
}
|
||||
|
||||
export const FETCH_DASHBOARDS_SUCCEEDED = 'FETCH_DASHBOARDS_SUCCEEDED';
|
||||
export function fetchDashboardsSucceeded(choices) {
|
||||
return { type: FETCH_DASHBOARDS_SUCCEEDED, choices };
|
||||
}
|
||||
|
||||
export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
|
||||
export function fetchDashboardsFailed(userId) {
|
||||
return { type: FETCH_DASHBOARDS_FAILED, userId };
|
||||
}
|
||||
|
||||
export function fetchDashboards(userId) {
|
||||
return function (dispatch) {
|
||||
const url = '/dashboardmodelviewasync/api/read?_flt_0_owners=' + userId;
|
||||
$.get(url, function (data, status) {
|
||||
if (status === 'success') {
|
||||
const choices = [];
|
||||
for (let i = 0; i < data.pks.length; i++) {
|
||||
choices.push({ value: data.pks[i], label: data.result[i].dashboard_title });
|
||||
}
|
||||
dispatch(fetchDashboardsSucceeded(choices));
|
||||
} else {
|
||||
dispatch(fetchDashboardsFailed(userId));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED';
|
||||
export function saveSliceFailed() {
|
||||
return { type: SAVE_SLICE_FAILED };
|
||||
}
|
||||
|
||||
export const REMOVE_SAVE_MODAL_ALERT = 'REMOVE_SAVE_MODAL_ALERT';
|
||||
export function removeSaveModalAlert() {
|
||||
return { type: REMOVE_SAVE_MODAL_ALERT };
|
||||
}
|
||||
|
||||
export function saveSlice(url) {
|
||||
return function (dispatch) {
|
||||
$.get(url, (data, status) => {
|
||||
if (status === 'success') {
|
||||
// Go to new slice url or dashboard url
|
||||
window.location = data;
|
||||
} else {
|
||||
dispatch(saveSliceFailed());
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const UPDATE_CHART_STATUS = 'UPDATE_CHART_STATUS';
|
||||
export function updateChartStatus(status) {
|
||||
return { type: UPDATE_CHART_STATUS, status };
|
||||
}
|
||||
|
||||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, force = false) {
|
||||
return function (dispatch) {
|
||||
const url = getExploreUrl(formData, 'json', force);
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
success(queryResponse) {
|
||||
dispatch(chartUpdateSucceeded(queryResponse));
|
||||
},
|
||||
error(err) {
|
||||
if (err.statusText === 'timeout') {
|
||||
dispatch(chartUpdateTimeout(err.statusText));
|
||||
} else if (err.statusText !== 'abort') {
|
||||
dispatch(chartUpdateFailed(err.responseJSON));
|
||||
}
|
||||
},
|
||||
timeout: QUERY_TIMEOUT_THRESHOLD,
|
||||
});
|
||||
dispatch(chartUpdateStarted(queryRequest));
|
||||
};
|
||||
}
|
||||
|
||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
||||
export function renderTriggered() {
|
||||
return { type: RENDER_TRIGGERED };
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Mustache from 'mustache';
|
||||
import { connect } from 'react-redux';
|
||||
import { Alert, Collapse, Panel } from 'react-bootstrap';
|
||||
import visMap from '../../../visualizations/main';
|
||||
import { d3format } from '../../modules/utils';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import CachedLabel from '../../components/CachedLabel';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
loading: 'warning',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
alert: PropTypes.string,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number.isRequired,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
viz_type: PropTypes.string.isRequired,
|
||||
formData: PropTypes.object,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
triggerRender: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
};
|
||||
|
||||
class ChartContainer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selector: `#${props.containerId}`,
|
||||
showStackTrace: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.queryResponse &&
|
||||
(
|
||||
prevProps.queryResponse !== this.props.queryResponse ||
|
||||
prevProps.height !== this.props.height ||
|
||||
this.props.triggerRender
|
||||
) && !this.props.queryResponse.error
|
||||
&& this.props.chartStatus !== 'failed'
|
||||
&& this.props.chartStatus !== 'stopped'
|
||||
&& this.props.chartStatus !== 'loading'
|
||||
) {
|
||||
this.renderViz();
|
||||
}
|
||||
}
|
||||
|
||||
getMockedSliceObject() {
|
||||
const props = this.props;
|
||||
const getHeight = () => {
|
||||
const headerHeight = this.props.standalone ? 0 : 100;
|
||||
return parseInt(props.height, 10) - headerHeight;
|
||||
};
|
||||
return {
|
||||
viewSqlQuery: this.props.queryResponse.query,
|
||||
containerId: props.containerId,
|
||||
selector: this.state.selector,
|
||||
formData: this.props.formData,
|
||||
container: {
|
||||
html: (data) => {
|
||||
// this should be a callback to clear the contents of the slice container
|
||||
$(this.state.selector).html(data);
|
||||
},
|
||||
css: (property, value) => {
|
||||
$(this.state.selector).css(property, value);
|
||||
},
|
||||
height: getHeight,
|
||||
show: () => { },
|
||||
get: n => ($(this.state.selector).get(n)),
|
||||
find: classname => ($(this.state.selector).find(classname)),
|
||||
},
|
||||
|
||||
width: () => this.chartContainerRef.getBoundingClientRect().width,
|
||||
|
||||
height: getHeight,
|
||||
|
||||
render_template: (s) => {
|
||||
const context = {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
};
|
||||
return Mustache.render(s, context);
|
||||
},
|
||||
|
||||
setFilter: () => {},
|
||||
|
||||
getFilters: () => (
|
||||
// return filter objects from viz.formData
|
||||
{}
|
||||
),
|
||||
|
||||
addFilter: () => {},
|
||||
|
||||
removeFilter: () => {},
|
||||
|
||||
done: () => {},
|
||||
clearError: () => {
|
||||
// no need to do anything here since Alert is closable
|
||||
// query button will also remove Alert
|
||||
},
|
||||
error() {},
|
||||
|
||||
d3format: (col, number) => {
|
||||
// mock d3format function in Slice object in superset.js
|
||||
const format = props.column_formats[col];
|
||||
return d3format(format, number);
|
||||
},
|
||||
|
||||
data: {
|
||||
csv_endpoint: getExploreUrl(this.props.formData, 'csv'),
|
||||
json_endpoint: getExploreUrl(this.props.formData, 'json'),
|
||||
standalone_endpoint: getExploreUrl(
|
||||
this.props.formData, 'standalone'),
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
removeAlert() {
|
||||
this.props.actions.removeChartAlert();
|
||||
}
|
||||
|
||||
runQuery() {
|
||||
this.props.actions.runQuery(this.props.formData, true);
|
||||
}
|
||||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice) {
|
||||
title = this.props.slice.slice_name;
|
||||
} else {
|
||||
title = `[${this.props.table_name}] - untitled`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
this.props.actions.renderTriggered();
|
||||
const mockSlice = this.getMockedSliceObject();
|
||||
this.setState({ mockSlice });
|
||||
try {
|
||||
visMap[this.props.viz_type](mockSlice, this.props.queryResponse);
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderAlert() {
|
||||
const msg = (
|
||||
<div>
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: this.props.alert }}
|
||||
/>
|
||||
</div>);
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
bsStyle="warning"
|
||||
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
|
||||
>
|
||||
{msg}
|
||||
</Alert>
|
||||
{this.props.queryResponse && this.props.queryResponse.stacktrace &&
|
||||
<Collapse in={this.state.showStackTrace}>
|
||||
<pre>
|
||||
{this.props.queryResponse.stacktrace}
|
||||
</pre>
|
||||
</Collapse>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
if (this.props.alert) {
|
||||
return this.renderAlert();
|
||||
}
|
||||
const loading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
<div>
|
||||
{loading &&
|
||||
<img
|
||||
alt="loading"
|
||||
width="25"
|
||||
src="/static/assets/images/loading.gif"
|
||||
style={{ position: 'absolute' }}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
id={this.props.containerId}
|
||||
ref={(ref) => { this.chartContainerRef = ref; }}
|
||||
className={this.props.viz_type}
|
||||
style={{
|
||||
opacity: loading ? '0.25' : '1',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
// dom manipulation hack to get rid of the boostrap theme's body background
|
||||
$('body').addClass('background-transparent');
|
||||
return this.renderChart();
|
||||
}
|
||||
const queryResponse = this.props.queryResponse;
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<Panel
|
||||
style={{ height: this.props.height }}
|
||||
header={
|
||||
<div
|
||||
id="slice-header"
|
||||
className="clearfix panel-title-large"
|
||||
>
|
||||
{this.renderChartTitle()}
|
||||
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
<FaveStar
|
||||
sliceId={this.props.slice.slice_id}
|
||||
actions={this.props.actions}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
|
||||
<TooltipWrapper
|
||||
label="edit-desc"
|
||||
tooltip="Edit Description"
|
||||
>
|
||||
<a
|
||||
className="edit-desc-icon"
|
||||
href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div className="pull-right">
|
||||
{this.props.chartStatus === 'success' &&
|
||||
this.props.queryResponse &&
|
||||
this.props.queryResponse.is_cached &&
|
||||
<CachedLabel
|
||||
onClick={this.runQuery.bind(this)}
|
||||
cachedTimestamp={queryResponse.cached_dttm}
|
||||
/>
|
||||
}
|
||||
<Timer
|
||||
startTime={this.props.chartUpdateStartTime}
|
||||
endTime={this.props.chartUpdateEndTime}
|
||||
isRunning={this.props.chartStatus === 'loading'}
|
||||
status={CHART_STATUS_MAP[this.props.chartStatus]}
|
||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||
/>
|
||||
<ExploreActionButtons
|
||||
slice={this.state.mockSlice}
|
||||
canDownload={this.props.can_download}
|
||||
chartStatus={this.props.chartStatus}
|
||||
queryResponse={queryResponse}
|
||||
queryEndpoint={getExploreUrl(this.props.latestQueryFormData, 'query')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{this.renderChart()}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const formData = getFormDataFromControls(state.controls);
|
||||
return {
|
||||
alert: state.chartAlert,
|
||||
can_download: state.can_download,
|
||||
chartStatus: state.chartStatus,
|
||||
chartUpdateEndTime: state.chartUpdateEndTime,
|
||||
chartUpdateStartTime: state.chartUpdateStartTime,
|
||||
column_formats: state.datasource ? state.datasource.column_formats : null,
|
||||
containerId: state.slice ? `slice-container-${state.slice.slice_id}` : 'slice-container',
|
||||
formData,
|
||||
latestQueryFormData: state.latestQueryFormData,
|
||||
isStarred: state.isStarred,
|
||||
queryResponse: state.queryResponse,
|
||||
slice: state.slice,
|
||||
standalone: state.standalone,
|
||||
table_name: formData.datasource_name,
|
||||
viz_type: formData.viz_type,
|
||||
triggerRender: state.triggerRender,
|
||||
datasourceType: state.datasource ? state.datasource.type : null,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, () => ({}))(ChartContainer);
|
||||
110
superset/assets/javascripts/explore/components/Control.jsx
Normal file
110
superset/assets/javascripts/explore/components/Control.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ControlHeader from './ControlHeader';
|
||||
|
||||
import CheckboxControl from './controls/CheckboxControl';
|
||||
import FilterControl from './controls/FilterControl';
|
||||
import HiddenControl from './controls/HiddenControl';
|
||||
import SelectControl from './controls/SelectControl';
|
||||
import TextAreaControl from './controls/TextAreaControl';
|
||||
import TextControl from './controls/TextControl';
|
||||
|
||||
const controlMap = {
|
||||
CheckboxControl,
|
||||
FilterControl,
|
||||
HiddenControl,
|
||||
SelectControl,
|
||||
TextAreaControl,
|
||||
TextControl,
|
||||
};
|
||||
const controlTypes = Object.keys(controlMap);
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf(controlTypes).isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
label: PropTypes.string.isRequired,
|
||||
choices: PropTypes.arrayOf(PropTypes.array),
|
||||
description: PropTypes.string,
|
||||
places: PropTypes.number,
|
||||
validators: PropTypes.array,
|
||||
validationErrors: PropTypes.array,
|
||||
renderTrigger: PropTypes.bool,
|
||||
rightNode: PropTypes.node,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
PropTypes.array]),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
renderTrigger: false,
|
||||
validators: [],
|
||||
hidden: false,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
export default class Control extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.validate = this.validate.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
this.validateAndSetValue(this.props.value, []);
|
||||
}
|
||||
onChange(value, errors) {
|
||||
this.validateAndSetValue(value, errors);
|
||||
}
|
||||
validateAndSetValue(value, errors) {
|
||||
let validationErrors = this.props.validationErrors;
|
||||
let currentErrors = this.validate(value);
|
||||
if (errors && errors.length > 0) {
|
||||
currentErrors = validationErrors.concat(errors);
|
||||
}
|
||||
if (validationErrors.length + currentErrors.length > 0) {
|
||||
validationErrors = currentErrors;
|
||||
}
|
||||
|
||||
if (value !== this.props.value || validationErrors !== this.props.validationErrors) {
|
||||
this.props.actions.setControlValue(this.props.name, value, validationErrors);
|
||||
}
|
||||
}
|
||||
validate(value) {
|
||||
const validators = this.props.validators;
|
||||
const validationErrors = [];
|
||||
if (validators && validators.length > 0) {
|
||||
validators.forEach((f) => {
|
||||
const v = f(value);
|
||||
if (v) {
|
||||
validationErrors.push(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
return validationErrors;
|
||||
}
|
||||
render() {
|
||||
const ControlType = controlMap[this.props.type];
|
||||
const divStyle = this.props.hidden ? { display: 'none' } : null;
|
||||
return (
|
||||
<div style={divStyle}>
|
||||
<ControlHeader
|
||||
label={this.props.label}
|
||||
description={this.props.description}
|
||||
renderTrigger={this.props.renderTrigger}
|
||||
validationErrors={this.props.validationErrors}
|
||||
rightNode={this.props.rightNode}
|
||||
/>
|
||||
<ControlType
|
||||
onChange={this.onChange}
|
||||
{...this.props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Control.propTypes = propTypes;
|
||||
Control.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ControlLabel, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
||||
|
||||
const propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
validationErrors: PropTypes.array,
|
||||
renderTrigger: PropTypes.bool,
|
||||
rightNode: PropTypes.node,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
validationErrors: [],
|
||||
renderTrigger: false,
|
||||
};
|
||||
|
||||
export default function ControlHeader({
|
||||
label, description, validationErrors, renderTrigger, rightNode }) {
|
||||
const hasError = (validationErrors.length > 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="pull-left">
|
||||
<ControlLabel>
|
||||
{hasError ?
|
||||
<strong className="text-danger">{label}</strong> :
|
||||
<span>{label}</span>
|
||||
}
|
||||
{' '}
|
||||
{(validationErrors.length > 0) &&
|
||||
<span>
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<Tooltip id={'error-tooltip'}>
|
||||
{validationErrors.join(' ')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<i className="fa fa-exclamation-circle text-danger" />
|
||||
</OverlayTrigger>
|
||||
{' '}
|
||||
</span>
|
||||
}
|
||||
{description &&
|
||||
<span>
|
||||
<InfoTooltipWithTrigger label={label} tooltip={description} />
|
||||
{' '}
|
||||
</span>
|
||||
}
|
||||
{renderTrigger &&
|
||||
<span>
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<Tooltip id={'rendertrigger-tooltip'}>
|
||||
Takes effect on chart immediatly
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<i className="fa fa-bolt text-muted" />
|
||||
</OverlayTrigger>
|
||||
{' '}
|
||||
</span>
|
||||
}
|
||||
</ControlLabel>
|
||||
</div>
|
||||
{rightNode &&
|
||||
<div className="pull-right">
|
||||
{rightNode}
|
||||
</div>
|
||||
}
|
||||
<div className="clearfix" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ControlHeader.propTypes = propTypes;
|
||||
ControlHeader.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
||||
|
||||
const propTypes = {
|
||||
label: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
label: null,
|
||||
description: null,
|
||||
tooltip: null,
|
||||
};
|
||||
|
||||
export default class ControlPanelSection extends React.Component {
|
||||
renderHeader() {
|
||||
const { label, tooltip } = this.props;
|
||||
let header;
|
||||
if (label) {
|
||||
header = (
|
||||
<div>
|
||||
{label}
|
||||
{tooltip && <InfoTooltipWithTrigger label={label} tooltip={tooltip} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Panel
|
||||
className="control-panel-section"
|
||||
header={this.renderHeader()}
|
||||
>
|
||||
{this.props.children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanelSection.propTypes = propTypes;
|
||||
ControlPanelSection.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,106 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import { Alert } from 'react-bootstrap';
|
||||
import { sectionsToRender } from '../stores/visTypes';
|
||||
import ControlPanelSection from './ControlPanelSection';
|
||||
import ControlRow from './ControlRow';
|
||||
import Control from './Control';
|
||||
import controls from '../stores/controls';
|
||||
import * as actions from '../actions/exploreActions';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
alert: PropTypes.string,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
exploreState: PropTypes.object.isRequired,
|
||||
controls: PropTypes.object.isRequired,
|
||||
form_data: PropTypes.object.isRequired,
|
||||
isDatasourceMetaLoading: PropTypes.bool.isRequired,
|
||||
y_axis_zero: PropTypes.any,
|
||||
};
|
||||
|
||||
class ControlPanelsContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.removeAlert = this.removeAlert.bind(this);
|
||||
this.getControlData = this.getControlData.bind(this);
|
||||
}
|
||||
getControlData(controlName) {
|
||||
const mapF = controls[controlName].mapStateToProps;
|
||||
if (mapF) {
|
||||
return Object.assign({}, this.props.controls[controlName], mapF(this.props.exploreState));
|
||||
}
|
||||
return this.props.controls[controlName];
|
||||
}
|
||||
sectionsToRender() {
|
||||
return sectionsToRender(this.props.form_data.viz_type, this.props.datasource_type);
|
||||
}
|
||||
removeAlert() {
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div className="scrollbar-container">
|
||||
<div className="scrollbar-content">
|
||||
{this.props.alert &&
|
||||
<Alert bsStyle="warning">
|
||||
{this.props.alert}
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Alert>
|
||||
}
|
||||
{this.sectionsToRender().map(section => (
|
||||
<ControlPanelSection
|
||||
key={section.label}
|
||||
label={section.label}
|
||||
tooltip={section.description}
|
||||
>
|
||||
{section.controlSetRows.map((controlSets, i) => (
|
||||
<ControlRow
|
||||
key={`controlsetrow-${i}`}
|
||||
controls={controlSets.map(controlName => (
|
||||
<Control
|
||||
name={controlName}
|
||||
key={`control-${controlName}`}
|
||||
value={this.props.form_data[controlName]}
|
||||
validationErrors={this.props.controls[controlName].validationErrors}
|
||||
actions={this.props.actions}
|
||||
{...this.getControlData(controlName)}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
))}
|
||||
</ControlPanelSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanelsContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
alert: state.controlPanelAlert,
|
||||
isDatasourceMetaLoading: state.isDatasourceMetaLoading,
|
||||
controls: state.controls,
|
||||
exploreState: state,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export { ControlPanelsContainer };
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ControlPanelsContainer);
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const NUM_COLUMNS = 12;
|
||||
|
||||
const propTypes = {
|
||||
controls: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
};
|
||||
|
||||
function ControlSetRow(props) {
|
||||
const colSize = NUM_COLUMNS / props.controls.length;
|
||||
return (
|
||||
<div className="row space-1">
|
||||
{props.controls.map((control, i) => (
|
||||
<div className={`col-lg-${colSize} col-xs-12`} key={i} >
|
||||
{control}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ControlSetRow.propTypes = propTypes;
|
||||
export default ControlSetRow;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { github } from 'react-syntax-highlighter/dist/styles';
|
||||
|
||||
import ModalTrigger from './../../components/ModalTrigger';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
const propTypes = {
|
||||
animation: PropTypes.bool,
|
||||
queryResponse: PropTypes.object,
|
||||
chartStatus: PropTypes.string,
|
||||
queryEndpoint: PropTypes.string.isRequired,
|
||||
};
|
||||
const defaultProps = {
|
||||
animation: true,
|
||||
};
|
||||
|
||||
export default class DisplayQueryButton extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
language: null,
|
||||
query: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
this.beforeOpen = this.beforeOpen.bind(this);
|
||||
this.fetchQuery = this.fetchQuery.bind(this);
|
||||
}
|
||||
setStateFromQueryResponse() {
|
||||
const qr = this.props.queryResponse;
|
||||
this.setState({
|
||||
language: qr.language,
|
||||
query: qr.query,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
fetchQuery() {
|
||||
this.setState({ isLoading: true });
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: this.props.queryEndpoint,
|
||||
success: (data) => {
|
||||
this.setState({
|
||||
language: data.language,
|
||||
query: data.query,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
error: (data) => {
|
||||
this.setState({
|
||||
error: data.responseJSON ? data.responseJSON.error : 'Error...',
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
beforeOpen() {
|
||||
if (['loading', null].indexOf(this.props.chartStatus) >= 0 || !this.props.queryResponse) {
|
||||
this.fetchQuery();
|
||||
} else {
|
||||
this.setStateFromQueryResponse();
|
||||
}
|
||||
}
|
||||
renderModalBody() {
|
||||
if (this.state.isLoading) {
|
||||
return (<img
|
||||
className="loading"
|
||||
alt="Loading..."
|
||||
src="/static/assets/images/loading.gif"
|
||||
/>);
|
||||
} else if (this.state.error) {
|
||||
return <pre>{this.state.error}</pre>;
|
||||
} else if (this.state.query) {
|
||||
return (
|
||||
<SyntaxHighlighter language={this.state.language} style={github}>
|
||||
{this.state.query}
|
||||
</SyntaxHighlighter>);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<ModalTrigger
|
||||
animation={this.props.animation}
|
||||
isButton
|
||||
triggerNode={<span>Query</span>}
|
||||
modalTitle="Query"
|
||||
bsSize="large"
|
||||
beforeOpen={this.beforeOpen}
|
||||
modalBody={this.renderModalBody()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DisplayQueryButton.propTypes = propTypes;
|
||||
DisplayQueryButton.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Popover, OverlayTrigger } from 'react-bootstrap';
|
||||
import CopyToClipboard from './../../components/CopyToClipboard';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default class EmbedCodeButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
height: '400',
|
||||
width: '600',
|
||||
};
|
||||
this.handleInputChange = this.handleInputChange.bind(this);
|
||||
}
|
||||
|
||||
handleInputChange(e) {
|
||||
const value = e.currentTarget.value;
|
||||
const name = e.currentTarget.name;
|
||||
const data = {};
|
||||
data[name] = value;
|
||||
this.setState(data);
|
||||
}
|
||||
|
||||
generateEmbedHTML() {
|
||||
const srcLink = (
|
||||
window.location.origin +
|
||||
this.props.slice.data.standalone_endpoint +
|
||||
`&height=${this.state.height}`
|
||||
);
|
||||
return (
|
||||
'<iframe\n' +
|
||||
` width="${this.state.width}"\n` +
|
||||
` height="${this.state.height}"\n` +
|
||||
' seamless\n' +
|
||||
' frameBorder="0"\n' +
|
||||
' scrolling="no"\n' +
|
||||
` src="${srcLink}"\n` +
|
||||
'>\n' +
|
||||
'</iframe>'
|
||||
);
|
||||
}
|
||||
|
||||
renderPopover() {
|
||||
const html = this.generateEmbedHTML();
|
||||
return (
|
||||
<Popover id="embed-code-popover">
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col-sm-10">
|
||||
<textarea
|
||||
name="embedCode"
|
||||
value={html}
|
||||
rows="4"
|
||||
readOnly
|
||||
className="form-control input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-2">
|
||||
<CopyToClipboard
|
||||
shouldShowText={false}
|
||||
text={html}
|
||||
copyNode={<i className="fa fa-clipboard" title="Copy to clipboard" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div className="row">
|
||||
<div className="col-md-6 col-sm-12">
|
||||
<div className="form-group">
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">Height</label>
|
||||
</small>
|
||||
<input
|
||||
className="form-control input-sm"
|
||||
type="text"
|
||||
defaultValue={this.state.height}
|
||||
name="height"
|
||||
onChange={this.handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6 col-sm-12">
|
||||
<div className="form-group">
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-width">Width</label>
|
||||
</small>
|
||||
<input
|
||||
className="form-control input-sm"
|
||||
type="text"
|
||||
defaultValue={this.state.width}
|
||||
name="width"
|
||||
onChange={this.handleInputChange}
|
||||
id="embed-width"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
rootClose
|
||||
placement="left"
|
||||
overlay={this.renderPopover()}
|
||||
>
|
||||
<span className="btn btn-default btn-sm">
|
||||
<i className="fa fa-code" />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EmbedCodeButton.propTypes = propTypes;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cx from 'classnames';
|
||||
import URLShortLinkButton from './URLShortLinkButton';
|
||||
import EmbedCodeButton from './EmbedCodeButton';
|
||||
import DisplayQueryButton from './DisplayQueryButton';
|
||||
|
||||
const propTypes = {
|
||||
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
|
||||
slice: PropTypes.object,
|
||||
queryEndpoint: PropTypes.string.isRequired,
|
||||
queryResponse: PropTypes.object,
|
||||
chartStatus: PropTypes.string,
|
||||
};
|
||||
|
||||
export default function ExploreActionButtons({
|
||||
chartStatus, canDownload, slice, queryResponse, queryEndpoint }) {
|
||||
const exportToCSVClasses = cx('btn btn-default btn-sm', {
|
||||
'disabled disabledButton': !canDownload,
|
||||
});
|
||||
if (slice) {
|
||||
return (
|
||||
<div className="btn-group results" role="group">
|
||||
<URLShortLinkButton slice={slice} />
|
||||
|
||||
<EmbedCodeButton slice={slice} />
|
||||
|
||||
<a
|
||||
href={slice.data.json_endpoint}
|
||||
className="btn btn-default btn-sm"
|
||||
title="Export to .json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa fa-file-code-o" /> .json
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={slice.data.csv_endpoint}
|
||||
className={exportToCSVClasses}
|
||||
title="Export to .csv format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa fa-file-text-o" /> .csv
|
||||
</a>
|
||||
|
||||
<DisplayQueryButton
|
||||
queryResponse={queryResponse}
|
||||
queryEndpoint={queryEndpoint}
|
||||
chartStatus={chartStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DisplayQueryButton queryEndpoint={queryEndpoint} />
|
||||
);
|
||||
}
|
||||
|
||||
ExploreActionButtons.propTypes = propTypes;
|
||||
@@ -0,0 +1,207 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import ChartContainer from './ChartContainer';
|
||||
import ControlPanelsContainer from './ControlPanelsContainer';
|
||||
import SaveModal from './SaveModal';
|
||||
import QueryAndSaveBtns from './QueryAndSaveBtns';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import * as actions from '../actions/exploreActions';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
controls: PropTypes.object.isRequired,
|
||||
forcedHeight: PropTypes.string,
|
||||
form_data: PropTypes.object.isRequired,
|
||||
standalone: PropTypes.bool.isRequired,
|
||||
triggerQuery: PropTypes.bool.isRequired,
|
||||
queryRequest: PropTypes.object,
|
||||
};
|
||||
|
||||
class ExploreViewContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
height: this.getHeight(),
|
||||
showModal: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.standalone) {
|
||||
this.props.actions.fetchDatasources();
|
||||
}
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
this.triggerQueryIfNeeded();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(np) {
|
||||
if (np.controls.viz_type.value !== this.props.controls.viz_type.value) {
|
||||
this.props.actions.resetControls();
|
||||
this.props.actions.triggerQuery();
|
||||
}
|
||||
if (np.controls.datasource.value !== this.props.controls.datasource.value) {
|
||||
this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.triggerQueryIfNeeded();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize.bind(this));
|
||||
}
|
||||
|
||||
onQuery() {
|
||||
// remove alerts when query
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
this.props.actions.removeChartAlert();
|
||||
|
||||
this.props.actions.triggerQuery();
|
||||
|
||||
history.pushState(
|
||||
{},
|
||||
document.title,
|
||||
getExploreUrl(this.props.form_data));
|
||||
}
|
||||
|
||||
onStop() {
|
||||
this.props.actions.chartUpdateStopped(this.props.queryRequest);
|
||||
}
|
||||
|
||||
getHeight() {
|
||||
if (this.props.forcedHeight) {
|
||||
return this.props.forcedHeight + 'px';
|
||||
}
|
||||
const navHeight = this.props.standalone ? 0 : 90;
|
||||
return `${window.innerHeight - navHeight}px`;
|
||||
}
|
||||
|
||||
|
||||
triggerQueryIfNeeded() {
|
||||
if (this.props.triggerQuery && !this.hasErrors()) {
|
||||
this.props.actions.runQuery(this.props.form_data);
|
||||
}
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
this.setState({ height: this.getHeight() });
|
||||
}, 250);
|
||||
}
|
||||
|
||||
toggleModal() {
|
||||
this.setState({ showModal: !this.state.showModal });
|
||||
}
|
||||
hasErrors() {
|
||||
const ctrls = this.props.controls;
|
||||
return Object.keys(ctrls).some(
|
||||
k => ctrls[k].validationErrors && ctrls[k].validationErrors.length > 0);
|
||||
}
|
||||
renderErrorMessage() {
|
||||
// Returns an error message as a node if any errors are in the store
|
||||
const errors = [];
|
||||
for (const controlName in this.props.controls) {
|
||||
const control = this.props.controls[controlName];
|
||||
if (control.validationErrors && control.validationErrors.length > 0) {
|
||||
errors.push(
|
||||
<div key={controlName}>
|
||||
<strong>{`[ ${control.label} ] `}</strong>
|
||||
{control.validationErrors.join('. ')}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
let errorMessage;
|
||||
if (errors.length > 0) {
|
||||
errorMessage = (
|
||||
<div style={{ textAlign: 'left' }}>{errors}</div>
|
||||
);
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
renderChartContainer() {
|
||||
return (
|
||||
<ChartContainer
|
||||
actions={this.props.actions}
|
||||
height={this.state.height}
|
||||
/>);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
return this.renderChartContainer();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id="explore-container"
|
||||
className="container-fluid"
|
||||
style={{
|
||||
height: this.state.height,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{this.state.showModal &&
|
||||
<SaveModal
|
||||
onHide={this.toggleModal.bind(this)}
|
||||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
/>
|
||||
}
|
||||
<div className="row">
|
||||
<div className="col-sm-4">
|
||||
<QueryAndSaveBtns
|
||||
canAdd="True"
|
||||
onQuery={this.onQuery.bind(this)}
|
||||
onSave={this.toggleModal.bind(this)}
|
||||
onStop={this.onStop.bind(this)}
|
||||
loading={this.props.chartStatus === 'loading'}
|
||||
errorMessage={this.renderErrorMessage()}
|
||||
/>
|
||||
<br />
|
||||
<ControlPanelsContainer
|
||||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
datasource_type={this.props.datasource_type}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-8">
|
||||
{this.renderChartContainer()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ExploreViewContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const form_data = getFormDataFromControls(state.controls);
|
||||
return {
|
||||
chartStatus: state.chartStatus,
|
||||
datasource_type: state.datasource_type,
|
||||
controls: state.controls,
|
||||
form_data,
|
||||
standalone: state.standalone,
|
||||
triggerQuery: state.triggerQuery,
|
||||
forcedHeight: state.forced_height,
|
||||
queryRequest: state.queryRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export { ExploreViewContainer };
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ExploreViewContainer);
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ButtonGroup, OverlayTrigger, Tooltip } from 'react-bootstrap';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import Button from '../../components/Button';
|
||||
|
||||
const propTypes = {
|
||||
canAdd: PropTypes.string.isRequired,
|
||||
onQuery: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func,
|
||||
onStop: PropTypes.func,
|
||||
loading: PropTypes.bool,
|
||||
errorMessage: PropTypes.node,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onStop: () => {},
|
||||
onSave: () => {},
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default function QueryAndSaveBtns(
|
||||
{ canAdd, onQuery, onSave, onStop, loading, errorMessage }) {
|
||||
const saveClasses = classnames({
|
||||
'disabled disabledButton': canAdd !== 'True',
|
||||
});
|
||||
const qryButtonStyle = errorMessage ? 'danger' : 'primary';
|
||||
const saveButtonDisabled = errorMessage ? true : loading;
|
||||
const qryOrStopButton = loading ? (
|
||||
<Button
|
||||
onClick={onStop}
|
||||
bsStyle="warning"
|
||||
>
|
||||
<i className="fa fa-stop-circle-o" /> Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="query"
|
||||
onClick={onQuery}
|
||||
bsStyle={qryButtonStyle}
|
||||
disabled={!!errorMessage}
|
||||
>
|
||||
<i className="fa fa-bolt" /> Query
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ButtonGroup className="query-and-save">
|
||||
{qryOrStopButton}
|
||||
<Button
|
||||
className={saveClasses}
|
||||
data-target="#save_modal"
|
||||
data-toggle="modal"
|
||||
disabled={saveButtonDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
<i className="fa fa-plus-circle" /> Save as
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{errorMessage &&
|
||||
<span>
|
||||
{' '}
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<Tooltip id={'query-error-tooltip'}>
|
||||
{errorMessage}
|
||||
</Tooltip>}
|
||||
>
|
||||
<i className="fa fa-exclamation-circle text-danger fa-lg" />
|
||||
</OverlayTrigger>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QueryAndSaveBtns.propTypes = propTypes;
|
||||
QueryAndSaveBtns.defaultProps = defaultProps;
|
||||
245
superset/assets/javascripts/explore/components/SaveModal.jsx
Normal file
245
superset/assets/javascripts/explore/components/SaveModal.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
import { Modal, Alert, Button, Radio } from 'react-bootstrap';
|
||||
import Select from 'react-select';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const propTypes = {
|
||||
can_overwrite: PropTypes.bool,
|
||||
onHide: PropTypes.func.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
form_data: PropTypes.object,
|
||||
user_id: PropTypes.string.isRequired,
|
||||
dashboards: PropTypes.array.isRequired,
|
||||
alert: PropTypes.string,
|
||||
slice: PropTypes.object,
|
||||
datasource: PropTypes.object,
|
||||
};
|
||||
|
||||
class SaveModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
saveToDashboardId: null,
|
||||
newDashboardName: '',
|
||||
newSliceName: '',
|
||||
dashboards: [],
|
||||
alert: null,
|
||||
action: props.can_overwrite ? 'overwrite' : 'saveas',
|
||||
addToDash: 'noSave',
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchDashboards(this.props.user_id);
|
||||
}
|
||||
onChange(name, event) {
|
||||
switch (name) {
|
||||
case 'newSliceName':
|
||||
this.setState({ newSliceName: event.target.value });
|
||||
break;
|
||||
case 'saveToDashboardId':
|
||||
this.setState({ saveToDashboardId: event.value });
|
||||
this.changeDash('existing');
|
||||
break;
|
||||
case 'newDashboardName':
|
||||
this.setState({ newDashboardName: event.target.value });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
changeAction(action) {
|
||||
this.setState({ action });
|
||||
}
|
||||
changeDash(dash) {
|
||||
this.setState({ addToDash: dash });
|
||||
}
|
||||
saveOrOverwrite(gotodash) {
|
||||
this.setState({ alert: null });
|
||||
this.props.actions.removeSaveModalAlert();
|
||||
const sliceParams = {};
|
||||
|
||||
let sliceName = null;
|
||||
sliceParams.action = this.state.action;
|
||||
if (this.props.slice && this.props.slice.slice_id) {
|
||||
sliceParams.slice_id = this.props.slice.slice_id;
|
||||
}
|
||||
if (sliceParams.action === 'saveas') {
|
||||
sliceName = this.state.newSliceName;
|
||||
if (sliceName === '') {
|
||||
this.setState({ alert: 'Please enter a slice name' });
|
||||
return;
|
||||
}
|
||||
sliceParams.slice_name = sliceName;
|
||||
} else {
|
||||
sliceParams.slice_name = this.props.slice.slice_name;
|
||||
}
|
||||
|
||||
const addToDash = this.state.addToDash;
|
||||
sliceParams.add_to_dash = addToDash;
|
||||
let dashboard = null;
|
||||
switch (addToDash) {
|
||||
case ('existing'):
|
||||
dashboard = this.state.saveToDashboardId;
|
||||
if (!dashboard) {
|
||||
this.setState({ alert: 'Please select a dashboard' });
|
||||
return;
|
||||
}
|
||||
sliceParams.save_to_dashboard_id = dashboard;
|
||||
break;
|
||||
case ('new'):
|
||||
dashboard = this.state.newDashboardName;
|
||||
if (dashboard === '') {
|
||||
this.setState({ alert: 'Please enter a dashboard name' });
|
||||
return;
|
||||
}
|
||||
sliceParams.new_dashboard_name = dashboard;
|
||||
break;
|
||||
default:
|
||||
dashboard = null;
|
||||
}
|
||||
sliceParams.goto_dash = gotodash;
|
||||
|
||||
const baseUrl = `/superset/explore/${this.props.datasource.type}/${this.props.datasource.id}/`;
|
||||
sliceParams.datasource_name = this.props.datasource.name;
|
||||
|
||||
const saveUrl = `${baseUrl}?form_data=` +
|
||||
`${encodeURIComponent(JSON.stringify(this.props.form_data))}` +
|
||||
`&${$.param(sliceParams, true)}`;
|
||||
this.props.actions.saveSlice(saveUrl);
|
||||
this.props.onHide();
|
||||
}
|
||||
removeAlert() {
|
||||
if (this.props.alert) {
|
||||
this.props.actions.removeSaveModalAlert();
|
||||
}
|
||||
this.setState({ alert: null });
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
show
|
||||
onHide={this.props.onHide}
|
||||
bsStyle="large"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Save A Slice
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{(this.state.alert || this.props.alert) &&
|
||||
<Alert>
|
||||
{this.state.alert ? this.state.alert : this.props.alert}
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Alert>
|
||||
}
|
||||
{this.props.slice &&
|
||||
<Radio
|
||||
id="overwrite-radio"
|
||||
disabled={!this.props.can_overwrite}
|
||||
checked={this.state.action === 'overwrite'}
|
||||
onChange={this.changeAction.bind(this, 'overwrite')}
|
||||
>
|
||||
{`Overwrite slice ${this.props.slice.slice_name}`}
|
||||
</Radio>
|
||||
}
|
||||
|
||||
<Radio
|
||||
id="saveas-radio"
|
||||
inline
|
||||
checked={this.state.action === 'saveas'}
|
||||
onChange={this.changeAction.bind(this, 'saveas')}
|
||||
> Save as
|
||||
</Radio>
|
||||
<input
|
||||
name="new_slice_name"
|
||||
placeholder="[slice name]"
|
||||
onChange={this.onChange.bind(this, 'newSliceName')}
|
||||
onFocus={this.changeAction.bind(this, 'saveas')}
|
||||
/>
|
||||
|
||||
|
||||
<br />
|
||||
<hr />
|
||||
|
||||
<Radio
|
||||
checked={this.state.addToDash === 'noSave'}
|
||||
onChange={this.changeDash.bind(this, 'noSave')}
|
||||
>
|
||||
Do not add to a dashboard
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
inline
|
||||
checked={this.state.addToDash === 'existing'}
|
||||
onChange={this.changeDash.bind(this, 'existing')}
|
||||
>
|
||||
Add slice to existing dashboard
|
||||
</Radio>
|
||||
<Select
|
||||
options={this.props.dashboards}
|
||||
onChange={this.onChange.bind(this, 'saveToDashboardId')}
|
||||
autoSize={false}
|
||||
value={this.state.saveToDashboardId}
|
||||
/>
|
||||
|
||||
<Radio
|
||||
inline
|
||||
checked={this.state.addToDash === 'new'}
|
||||
onChange={this.changeDash.bind(this, 'new')}
|
||||
>
|
||||
Add to new dashboard
|
||||
</Radio>
|
||||
<input
|
||||
onChange={this.onChange.bind(this, 'newDashboardName')}
|
||||
onFocus={this.changeDash.bind(this, 'new')}
|
||||
placeholder="[dashboard name]"
|
||||
/>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
id="btn_modal_save"
|
||||
className="btn pull-left"
|
||||
onClick={this.saveOrOverwrite.bind(this, false)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
id="btn_modal_save_goto_dash"
|
||||
className="btn btn-primary pull-left gotodash"
|
||||
disabled={this.state.addToDash === 'noSave'}
|
||||
onClick={this.saveOrOverwrite.bind(this, true)}
|
||||
>
|
||||
Save & go to dashboard
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SaveModal.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
datasource: state.datasource,
|
||||
slice: state.slice,
|
||||
can_overwrite: state.can_overwrite,
|
||||
user_id: state.user_id,
|
||||
dashboards: state.dashboards,
|
||||
alert: state.saveModalAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export { SaveModal };
|
||||
export default connect(mapStateToProps, () => ({}))(SaveModal);
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Popover, OverlayTrigger } from 'react-bootstrap';
|
||||
import CopyToClipboard from './../../components/CopyToClipboard';
|
||||
import { getShortUrl } from '../../../utils/common';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default class URLShortLinkButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
shortUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
onShortUrlSuccess(data) {
|
||||
this.setState({
|
||||
shortUrl: data,
|
||||
});
|
||||
}
|
||||
|
||||
getCopyUrl() {
|
||||
const longUrl = window.location.pathname + window.location.search;
|
||||
getShortUrl(longUrl, this.onShortUrlSuccess.bind(this));
|
||||
}
|
||||
|
||||
renderPopover() {
|
||||
const emailBody = `Check out this slice: ${this.state.shortUrl}`;
|
||||
return (
|
||||
<Popover id="shorturl-popover">
|
||||
<CopyToClipboard
|
||||
text={this.state.shortUrl}
|
||||
copyNode={<i className="fa fa-clipboard" title="Copy to clipboard" />}
|
||||
/>
|
||||
|
||||
<a href={`mailto:?Subject=Superset%20Slice%20&Body=${emailBody}`}>
|
||||
<i className="fa fa-envelope" />
|
||||
</a>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
rootClose
|
||||
placement="left"
|
||||
onEnter={this.getCopyUrl.bind(this)}
|
||||
overlay={this.renderPopover()}
|
||||
>
|
||||
<span className="btn btn-default btn-sm">
|
||||
<i className="fa fa-link" />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
URLShortLinkButton.propTypes = propTypes;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Checkbox } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
value: false,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default class CheckboxControl extends React.Component {
|
||||
onToggle() {
|
||||
this.props.onChange(!this.props.value);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={this.props.value}
|
||||
onChange={this.onToggle.bind(this)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CheckboxControl.propTypes = propTypes;
|
||||
CheckboxControl.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Select from 'react-select';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import SelectControl from './SelectControl';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
const operatorsArr = [
|
||||
{ val: 'in', type: 'array', useSelect: true, multi: true },
|
||||
{ val: 'not in', type: 'array', useSelect: true, multi: true },
|
||||
{ val: '==', type: 'string', useSelect: true, multi: false, havingOnly: true },
|
||||
{ val: '!=', type: 'string', useSelect: true, multi: false, havingOnly: true },
|
||||
{ val: '>=', type: 'string', havingOnly: true },
|
||||
{ val: '<=', type: 'string', havingOnly: true },
|
||||
{ val: '>', type: 'string', havingOnly: true },
|
||||
{ val: '<', type: 'string', havingOnly: true },
|
||||
{ val: 'regex', type: 'string', datasourceTypes: ['druid'] },
|
||||
{ val: 'LIKE', type: 'string', datasourceTypes: ['table'] },
|
||||
];
|
||||
const operators = {};
|
||||
operatorsArr.forEach((op) => {
|
||||
operators[op.val] = op;
|
||||
});
|
||||
|
||||
const propTypes = {
|
||||
changeFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
filter: PropTypes.object.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
having: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
changeFilter: () => {},
|
||||
removeFilter: () => {},
|
||||
datasource: null,
|
||||
having: false,
|
||||
};
|
||||
|
||||
export default class Filter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
valuesLoading: false,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
this.fetchFilterValues(this.props.filter.col);
|
||||
}
|
||||
fetchFilterValues(col) {
|
||||
const datasource = this.props.datasource;
|
||||
if (col && this.props.datasource && this.props.datasource.filter_select && !this.props.having) {
|
||||
this.setState({ valuesLoading: true });
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`,
|
||||
success: (data) => {
|
||||
this.setState({ valuesLoading: false, valueChoices: data });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
switchFilterValue(prevOp, nextOp) {
|
||||
if (operators[prevOp].type !== operators[nextOp].type) {
|
||||
const val = this.props.filter.value;
|
||||
let newVal;
|
||||
// switch from array to string
|
||||
if (operators[nextOp].type === 'string' && val && val.length > 0) {
|
||||
newVal = val[0];
|
||||
} else if (operators[nextOp].type === 'string' && val) {
|
||||
newVal = [val];
|
||||
}
|
||||
this.props.changeFilter('val', newVal);
|
||||
}
|
||||
}
|
||||
changeText(event) {
|
||||
this.props.changeFilter('val', event.target.value);
|
||||
}
|
||||
changeSelect(value) {
|
||||
this.props.changeFilter('val', value);
|
||||
}
|
||||
changeColumn(event) {
|
||||
this.props.changeFilter('col', event.value);
|
||||
this.fetchFilterValues(event.value);
|
||||
}
|
||||
changeOp(event) {
|
||||
this.switchFilterValue(this.props.filter.op, event.value);
|
||||
this.props.changeFilter('op', event.value);
|
||||
}
|
||||
removeFilter(filter) {
|
||||
this.props.removeFilter(filter);
|
||||
}
|
||||
renderFilterFormControl(filter) {
|
||||
const operator = operators[filter.op];
|
||||
if (operator.useSelect && !this.props.having) {
|
||||
return (
|
||||
<SelectControl
|
||||
multi={operator.multi}
|
||||
freeForm
|
||||
name="filter-value"
|
||||
value={filter.val}
|
||||
isLoading={this.state.valuesLoading}
|
||||
choices={this.state.valueChoices}
|
||||
onChange={this.changeSelect.bind(this)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
onChange={this.changeText.bind(this)}
|
||||
value={filter.val}
|
||||
className="form-control input-sm"
|
||||
placeholder="Filter value"
|
||||
/>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const datasource = this.props.datasource;
|
||||
const filter = this.props.filter;
|
||||
const opsChoices = operatorsArr
|
||||
.filter((o) => {
|
||||
if (this.props.having) {
|
||||
return !!o.havingOnly;
|
||||
}
|
||||
return (!o.datasourceTypes || o.datasourceTypes.indexOf(datasource.type) >= 0);
|
||||
})
|
||||
.map(o => ({ value: o.val, label: o.val }));
|
||||
let colChoices;
|
||||
if (datasource) {
|
||||
if (this.props.having) {
|
||||
colChoices = datasource.metrics_combo.map(c => ({ value: c[0], label: c[1] }));
|
||||
} else {
|
||||
colChoices = datasource.filterable_cols.map(c => ({ value: c[0], label: c[1] }));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Row className="space-1">
|
||||
<Col md={12}>
|
||||
<Select
|
||||
id="select-col"
|
||||
placeholder={this.props.having ? 'Select metric' : 'Select column'}
|
||||
clearable={false}
|
||||
options={colChoices}
|
||||
value={filter.col}
|
||||
onChange={this.changeColumn.bind(this)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="space-1">
|
||||
<Col md={3}>
|
||||
<Select
|
||||
id="select-op"
|
||||
placeholder="Select operator"
|
||||
options={opsChoices}
|
||||
clearable={false}
|
||||
value={filter.op}
|
||||
onChange={this.changeOp.bind(this)}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={7}>
|
||||
{this.renderFilterFormControl(filter)}
|
||||
</Col>
|
||||
<Col md={2}>
|
||||
<Button
|
||||
id="remove-button"
|
||||
bsSize="small"
|
||||
onClick={this.removeFilter.bind(this)}
|
||||
>
|
||||
<i className="fa fa-minus" />
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Filter.propTypes = propTypes;
|
||||
Filter.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Row, Col } from 'react-bootstrap';
|
||||
import Filter from './Filter';
|
||||
|
||||
const propTypes = {
|
||||
name: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.array,
|
||||
datasource: PropTypes.object,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => {},
|
||||
value: [],
|
||||
};
|
||||
|
||||
export default class FilterControl extends React.Component {
|
||||
addFilter() {
|
||||
const newFilters = Object.assign([], this.props.value);
|
||||
const col = this.props.datasource && this.props.datasource.filterable_cols.length > 0 ?
|
||||
this.props.datasource.filterable_cols[0][0] :
|
||||
null;
|
||||
newFilters.push({
|
||||
col,
|
||||
op: 'in',
|
||||
val: this.props.datasource.filter_select ? [] : '',
|
||||
});
|
||||
this.props.onChange(newFilters);
|
||||
}
|
||||
changeFilter(index, control, value) {
|
||||
const newFilters = Object.assign([], this.props.value);
|
||||
const modifiedFilter = Object.assign({}, newFilters[index]);
|
||||
if (typeof control === 'string') {
|
||||
modifiedFilter[control] = value;
|
||||
} else {
|
||||
control.forEach((c, i) => {
|
||||
modifiedFilter[c] = value[i];
|
||||
});
|
||||
}
|
||||
newFilters.splice(index, 1, modifiedFilter);
|
||||
this.props.onChange(newFilters);
|
||||
}
|
||||
removeFilter(index) {
|
||||
this.props.onChange(this.props.value.filter((f, i) => i !== index));
|
||||
}
|
||||
render() {
|
||||
const filters = this.props.value.map((filter, i) => (
|
||||
<div key={i}>
|
||||
<Filter
|
||||
having={this.props.name === 'having_filters'}
|
||||
filter={filter}
|
||||
datasource={this.props.datasource}
|
||||
removeFilter={this.removeFilter.bind(this, i)}
|
||||
changeFilter={this.changeFilter.bind(this, i)}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
{filters}
|
||||
<Row className="space-2">
|
||||
<Col md={2}>
|
||||
<Button
|
||||
id="add-button"
|
||||
bsSize="sm"
|
||||
onClick={this.addFilter.bind(this)}
|
||||
>
|
||||
<i className="fa fa-plus" /> Add Filter
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FilterControl.propTypes = propTypes;
|
||||
FilterControl.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormControl } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default function HiddenControl(props) {
|
||||
// This wouldn't be necessary but might as well
|
||||
return <FormControl type="hidden" value={props.value} />;
|
||||
}
|
||||
|
||||
HiddenControl.propTypes = propTypes;
|
||||
HiddenControl.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Select, { Creatable } from 'react-select';
|
||||
|
||||
const propTypes = {
|
||||
choices: PropTypes.array,
|
||||
clearable: PropTypes.bool,
|
||||
description: PropTypes.string,
|
||||
freeForm: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
multi: PropTypes.bool,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
choices: [],
|
||||
clearable: true,
|
||||
description: null,
|
||||
freeForm: false,
|
||||
isLoading: false,
|
||||
label: null,
|
||||
multi: false,
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
export default class SelectControl extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { options: this.getOptions(props) };
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.renderOption = this.renderOption.bind(this);
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.choices !== this.props.choices) {
|
||||
const options = this.getOptions(nextProps);
|
||||
this.setState({ options });
|
||||
}
|
||||
}
|
||||
onChange(opt) {
|
||||
let optionValue = opt ? opt.value : null;
|
||||
// if multi, return options values as an array
|
||||
if (this.props.multi) {
|
||||
optionValue = opt ? opt.map(o => o.value) : null;
|
||||
}
|
||||
this.props.onChange(optionValue);
|
||||
}
|
||||
getOptions(props) {
|
||||
// Accepts different formats of input
|
||||
const options = props.choices.map((c) => {
|
||||
let option;
|
||||
if (Array.isArray(c)) {
|
||||
const label = c.length > 1 ? c[1] : c[0];
|
||||
option = {
|
||||
value: c[0],
|
||||
label,
|
||||
};
|
||||
if (c[2]) option.imgSrc = c[2];
|
||||
} else if (Object.is(c)) {
|
||||
option = c;
|
||||
} else {
|
||||
option = {
|
||||
value: c,
|
||||
label: c,
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
if (props.freeForm) {
|
||||
// For FreeFormSelect, insert value into options if not exist
|
||||
const values = options.map(c => c.value);
|
||||
if (props.value) {
|
||||
let valuesToAdd = props.value;
|
||||
if (!Array.isArray(valuesToAdd)) {
|
||||
valuesToAdd = [valuesToAdd];
|
||||
}
|
||||
valuesToAdd.forEach((v) => {
|
||||
if (values.indexOf(v) < 0) {
|
||||
options.push({ value: v, label: v });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
renderOption(opt) {
|
||||
if (opt.imgSrc) {
|
||||
return (
|
||||
<div>
|
||||
<img className="viz-thumb-option" src={opt.imgSrc} alt={opt.value} />
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return opt.label;
|
||||
}
|
||||
render() {
|
||||
// Tab, comma or Enter will trigger a new option created for FreeFormSelect
|
||||
const selectProps = {
|
||||
multi: this.props.multi,
|
||||
name: `select-${this.props.name}`,
|
||||
placeholder: `Select (${this.state.options.length})`,
|
||||
options: this.state.options,
|
||||
value: this.props.value,
|
||||
autosize: false,
|
||||
clearable: this.props.clearable,
|
||||
isLoading: this.props.isLoading,
|
||||
onChange: this.onChange,
|
||||
optionRenderer: this.renderOption,
|
||||
};
|
||||
// Tab, comma or Enter will trigger a new option created for FreeFormSelect
|
||||
const selectWrap = this.props.freeForm ?
|
||||
(<Creatable {...selectProps} />) : (<Select {...selectProps} />);
|
||||
return (
|
||||
<div>
|
||||
{selectWrap}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectControl.propTypes = propTypes;
|
||||
SelectControl.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormGroup, FormControl } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
label: null,
|
||||
description: null,
|
||||
onChange: () => {},
|
||||
value: '',
|
||||
};
|
||||
|
||||
export default class TextAreaControl extends React.Component {
|
||||
onChange(event) {
|
||||
this.props.onChange(event.target.value);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<FormGroup controlId="formControlsTextarea">
|
||||
<FormControl
|
||||
componentClass="textarea"
|
||||
placeholder="textarea"
|
||||
onChange={this.onChange.bind(this)}
|
||||
value={this.props.value}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextAreaControl.propTypes = propTypes;
|
||||
TextAreaControl.defaultProps = defaultProps;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormGroup, FormControl } from 'react-bootstrap';
|
||||
import * as v from '../../validators';
|
||||
|
||||
const propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
isFloat: PropTypes.bool,
|
||||
isInt: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
label: null,
|
||||
description: null,
|
||||
onChange: () => {},
|
||||
value: '',
|
||||
isInt: false,
|
||||
isFloat: false,
|
||||
};
|
||||
|
||||
export default class TextControl extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const value = props.value ? props.value.toString() : '';
|
||||
this.state = { value };
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
onChange(event) {
|
||||
let value = event.target.value || '';
|
||||
this.setState({ value });
|
||||
|
||||
// Validation & casting
|
||||
const errors = [];
|
||||
if (this.props.isFloat) {
|
||||
const error = v.numeric(value);
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
}
|
||||
if (this.props.isInt) {
|
||||
const error = v.integer(value);
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
}
|
||||
this.props.onChange(value, errors);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<FormGroup controlId="formInlineName" bsSize="small">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder=""
|
||||
onChange={this.onChange}
|
||||
value={this.state.value}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TextControl.propTypes = propTypes;
|
||||
TextControl.defaultProps = defaultProps;
|
||||
43
superset/assets/javascripts/explore/exploreUtils.js
Normal file
43
superset/assets/javascripts/explore/exploreUtils.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import URI from 'urijs';
|
||||
|
||||
export function getExploreUrl(form_data, endpointType = 'base', force = false, curUrl = null) {
|
||||
if (!form_data.datasource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// The search params from the window.location are carried through,
|
||||
// but can be specified with curUrl (used for unit tests to spoof
|
||||
// the window.location).
|
||||
let uri = URI(window.location.search);
|
||||
if (curUrl) {
|
||||
uri = URI(URI(curUrl).search());
|
||||
}
|
||||
|
||||
// Building the directory part of the URI
|
||||
let directory = '/superset/explore/';
|
||||
if (['json', 'csv', 'query'].indexOf(endpointType) >= 0) {
|
||||
directory = '/superset/explore_json/';
|
||||
}
|
||||
const [datasource_id, datasource_type] = form_data.datasource.split('__');
|
||||
directory += `${datasource_type}/${datasource_id}/`;
|
||||
|
||||
// Building the querystring (search) part of the URI
|
||||
const search = uri.search(true);
|
||||
search.form_data = JSON.stringify(form_data);
|
||||
if (force) {
|
||||
search.force = 'true';
|
||||
}
|
||||
if (endpointType === 'csv') {
|
||||
search.csv = 'true';
|
||||
}
|
||||
if (endpointType === 'standalone') {
|
||||
search.standalone = 'true';
|
||||
}
|
||||
if (endpointType === 'query') {
|
||||
search.query = 'true';
|
||||
}
|
||||
uri = uri.search(search).directory(directory);
|
||||
return uri.toString();
|
||||
}
|
||||
58
superset/assets/javascripts/explore/index.jsx
Normal file
58
superset/assets/javascripts/explore/index.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { now } from '../modules/dates';
|
||||
import { initEnhancer } from '../reduxUtils';
|
||||
import AlertsWrapper from '../components/AlertsWrapper';
|
||||
import { getControlsState, getFormDataFromControls } from './stores/store';
|
||||
import { initJQueryAjax } from '../modules/utils';
|
||||
import ExploreViewContainer from './components/ExploreViewContainer';
|
||||
import { exploreReducer } from './reducers/exploreReducer';
|
||||
import { appSetup } from '../common';
|
||||
import './main.css';
|
||||
|
||||
appSetup();
|
||||
initJQueryAjax();
|
||||
|
||||
const exploreViewContainer = document.getElementById('js-explore-view-container');
|
||||
const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap'));
|
||||
const controls = getControlsState(bootstrapData, bootstrapData.form_data);
|
||||
delete bootstrapData.form_data;
|
||||
|
||||
|
||||
// Initial state
|
||||
const bootstrappedState = Object.assign(
|
||||
bootstrapData, {
|
||||
chartStatus: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
dashboards: [],
|
||||
controls,
|
||||
latestQueryFormData: getFormDataFromControls(controls),
|
||||
filterColumnOpts: [],
|
||||
isDatasourceMetaLoading: false,
|
||||
isStarred: false,
|
||||
queryResponse: null,
|
||||
triggerQuery: true,
|
||||
triggerRender: false,
|
||||
alert: null,
|
||||
},
|
||||
);
|
||||
|
||||
const store = createStore(exploreReducer, bootstrappedState,
|
||||
compose(applyMiddleware(thunk), initEnhancer(false)),
|
||||
);
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<div>
|
||||
<ExploreViewContainer />
|
||||
<AlertsWrapper />
|
||||
</div>
|
||||
</Provider>,
|
||||
exploreViewContainer,
|
||||
);
|
||||
36
superset/assets/javascripts/explore/main.css
Normal file
36
superset/assets/javascripts/explore/main.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.scrollbar-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scrollbar-content {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
overflow-y: auto;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.fave-unfave-icon, .edit-desc-icon {
|
||||
padding: 0 0 0 .5em;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
float: left;
|
||||
margin-top: 0px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.control-panel-section {
|
||||
margin-bottom: 0px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.background-transparent {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
162
superset/assets/javascripts/explore/reducers/exploreReducer.js
Normal file
162
superset/assets/javascripts/explore/reducers/exploreReducer.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { getControlsState, getFormDataFromControls } from '../stores/store';
|
||||
import * as actions from '../actions/exploreActions';
|
||||
import { now } from '../../modules/dates';
|
||||
import { QUERY_TIMEOUT_THRESHOLD } from '../../constants';
|
||||
|
||||
export const exploreReducer = function (state, action) {
|
||||
const actionHandlers = {
|
||||
[actions.TOGGLE_FAVE_STAR]() {
|
||||
return Object.assign({}, state, { isStarred: action.isStarred });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCE_STARTED]() {
|
||||
return Object.assign({}, state, { isDatasourceMetaLoading: true });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCE_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { isDatasourceMetaLoading: false });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCE_FAILED]() {
|
||||
// todo(alanna) handle failure/error state
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
isDatasourceMetaLoading: false,
|
||||
controlPanelAlert: action.error,
|
||||
});
|
||||
},
|
||||
[actions.SET_DATASOURCE]() {
|
||||
return Object.assign({}, state, { datasource: action.datasource });
|
||||
},
|
||||
[actions.FETCH_DATASOURCES_STARTED]() {
|
||||
return Object.assign({}, state, { isDatasourcesLoading: true });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCES_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { isDatasourcesLoading: false });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCES_FAILED]() {
|
||||
// todo(alanna) handle failure/error state
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
isDatasourcesLoading: false,
|
||||
controlPanelAlert: action.error,
|
||||
});
|
||||
},
|
||||
[actions.SET_DATASOURCES]() {
|
||||
return Object.assign({}, state, { datasources: action.datasources });
|
||||
},
|
||||
[actions.REMOVE_CONTROL_PANEL_ALERT]() {
|
||||
return Object.assign({}, state, { controlPanelAlert: null });
|
||||
},
|
||||
[actions.FETCH_DASHBOARDS_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { dashboards: action.choices });
|
||||
},
|
||||
|
||||
[actions.FETCH_DASHBOARDS_FAILED]() {
|
||||
return Object.assign({}, state,
|
||||
{ saveModalAlert: `fetching dashboards failed for ${action.userId}` });
|
||||
},
|
||||
[actions.SET_FIELD_VALUE]() {
|
||||
const controls = Object.assign({}, state.controls);
|
||||
const control = Object.assign({}, controls[action.controlName]);
|
||||
control.value = action.value;
|
||||
control.validationErrors = action.validationErrors;
|
||||
controls[action.controlName] = control;
|
||||
const changes = { controls };
|
||||
if (control.renderTrigger) {
|
||||
changes.triggerRender = true;
|
||||
}
|
||||
return Object.assign({}, state, changes);
|
||||
},
|
||||
[actions.CHART_UPDATE_SUCCEEDED]() {
|
||||
return Object.assign(
|
||||
{},
|
||||
state,
|
||||
{
|
||||
chartStatus: 'success',
|
||||
queryResponse: action.queryResponse,
|
||||
},
|
||||
);
|
||||
},
|
||||
[actions.CHART_UPDATE_STARTED]() {
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
triggerQuery: false,
|
||||
queryRequest: action.queryRequest,
|
||||
latestQueryFormData: getFormDataFromControls(state.controls),
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_STOPPED]() {
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
chartStatus: 'stopped',
|
||||
chartAlert: 'Updating chart was stopped',
|
||||
});
|
||||
},
|
||||
[actions.CHART_RENDERING_FAILED]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: 'An error occurred while rendering the visualization: ' + action.error,
|
||||
});
|
||||
},
|
||||
[actions.TRIGGER_QUERY]() {
|
||||
return Object.assign({}, state, {
|
||||
triggerQuery: true,
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_TIMEOUT]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: '<strong>Query timeout</strong> - visualization query are set to timeout at ' +
|
||||
`${QUERY_TIMEOUT_THRESHOLD / 1000} seconds. ` +
|
||||
'Perhaps your data has grown, your database is under unusual load, ' +
|
||||
'or you are simply querying a data source that is to large to be processed within the timeout range. ' +
|
||||
'If that is the case, we recommend that you summarize your data further.',
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_FAILED]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse ? action.queryResponse.error : 'Network error.',
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
});
|
||||
},
|
||||
[actions.UPDATE_CHART_STATUS]() {
|
||||
const newState = Object.assign({}, state, { chartStatus: action.status });
|
||||
if (action.status === 'success' || action.status === 'failed') {
|
||||
newState.chartUpdateEndTime = now();
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
[actions.REMOVE_CHART_ALERT]() {
|
||||
if (state.chartAlert !== null) {
|
||||
return Object.assign({}, state, { chartAlert: null });
|
||||
}
|
||||
return state;
|
||||
},
|
||||
[actions.SAVE_SLICE_FAILED]() {
|
||||
return Object.assign({}, state, { saveModalAlert: 'Failed to save slice' });
|
||||
},
|
||||
[actions.REMOVE_SAVE_MODAL_ALERT]() {
|
||||
return Object.assign({}, state, { saveModalAlert: null });
|
||||
},
|
||||
[actions.RESET_FIELDS]() {
|
||||
const controls = getControlsState(state, getFormDataFromControls(state.controls));
|
||||
return Object.assign({}, state, { controls });
|
||||
},
|
||||
[actions.RENDER_TRIGGERED]() {
|
||||
return Object.assign({}, state, { triggerRender: false });
|
||||
},
|
||||
};
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
}
|
||||
return state;
|
||||
};
|
||||
1233
superset/assets/javascripts/explore/stores/controls.jsx
Normal file
1233
superset/assets/javascripts/explore/stores/controls.jsx
Normal file
File diff suppressed because it is too large
Load Diff
115
superset/assets/javascripts/explore/stores/store.js
Normal file
115
superset/assets/javascripts/explore/stores/store.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import controls from './controls';
|
||||
import visTypes, { sectionsToRender } from './visTypes';
|
||||
|
||||
export function getFormDataFromControls(controlsState) {
|
||||
const formData = {};
|
||||
Object.keys(controlsState).forEach((controlName) => {
|
||||
formData[controlName] = controlsState[controlName].value;
|
||||
});
|
||||
return formData;
|
||||
}
|
||||
|
||||
export function getControlNames(vizType, datasourceType) {
|
||||
const controlNames = [];
|
||||
sectionsToRender(vizType, datasourceType).forEach(
|
||||
section => section.controlSetRows.forEach(
|
||||
fsr => fsr.forEach(
|
||||
f => controlNames.push(f))));
|
||||
return controlNames;
|
||||
}
|
||||
|
||||
export function getControlsState(state, form_data) {
|
||||
/*
|
||||
* Gets a new controls object to put in the state. The controls object
|
||||
* is similar to the configuration control with only the controls
|
||||
* related to the current viz_type, materializes mapStateToProps functions,
|
||||
* adds value keys coming from form_data passed here. This can't be an action creator
|
||||
* just yet because it's used in both the explore and dashboard views.
|
||||
* */
|
||||
|
||||
// Getting a list of active control names for the current viz
|
||||
const formData = Object.assign({}, form_data);
|
||||
const vizType = formData.viz_type || 'table';
|
||||
|
||||
const controlNames = getControlNames(vizType, state.datasource.type);
|
||||
|
||||
const viz = visTypes[vizType];
|
||||
const controlOverrides = viz.controlOverrides || {};
|
||||
const controlsState = {};
|
||||
controlNames.forEach((k) => {
|
||||
const control = Object.assign({}, controls[k], controlOverrides[k]);
|
||||
if (control.mapStateToProps) {
|
||||
Object.assign(control, control.mapStateToProps(state));
|
||||
delete control.mapStateToProps;
|
||||
}
|
||||
|
||||
// If the value is not valid anymore based on choices, clear it
|
||||
if (control.type === 'SelectControl' && control.choices && k !== 'datasource' && formData[k]) {
|
||||
const choiceValues = control.choices.map(c => c[0]);
|
||||
if (control.multi && formData[k].length > 0 && choiceValues.indexOf(formData[k][0]) < 0) {
|
||||
delete formData[k];
|
||||
} else if (!control.multi && !control.freeForm && choiceValues.indexOf(formData[k]) < 0) {
|
||||
delete formData[k];
|
||||
}
|
||||
}
|
||||
// Removing invalid filters that point to a now inexisting column
|
||||
if (control.type === 'FilterControl' && control.choices) {
|
||||
if (!formData[k]) {
|
||||
formData[k] = [];
|
||||
}
|
||||
const choiceValues = control.choices.map(c => c[0]);
|
||||
formData[k] = formData[k].filter(flt => choiceValues.indexOf(flt.col) >= 0);
|
||||
}
|
||||
|
||||
if (typeof control.default === 'function') {
|
||||
control.default = control.default(control);
|
||||
}
|
||||
control.validationErrors = [];
|
||||
control.value = formData[k] !== undefined ? formData[k] : control.default;
|
||||
controlsState[k] = control;
|
||||
});
|
||||
return controlsState;
|
||||
}
|
||||
|
||||
export function applyDefaultFormData(form_data) {
|
||||
const datasourceType = form_data.datasource.split('__')[1];
|
||||
const vizType = form_data.viz_type || 'table';
|
||||
const viz = visTypes[vizType];
|
||||
const controlNames = getControlNames(vizType, datasourceType);
|
||||
const controlOverrides = viz.controlOverrides || {};
|
||||
const formData = {};
|
||||
controlNames.forEach((k) => {
|
||||
const control = Object.assign({}, controls[k]);
|
||||
if (controlOverrides[k]) {
|
||||
Object.assign(control, controlOverrides[k]);
|
||||
}
|
||||
if (form_data[k] === undefined) {
|
||||
if (typeof control.default === 'function') {
|
||||
formData[k] = control.default(controls[k]);
|
||||
} else {
|
||||
formData[k] = control.default;
|
||||
}
|
||||
} else {
|
||||
formData[k] = form_data[k];
|
||||
}
|
||||
});
|
||||
return formData;
|
||||
}
|
||||
|
||||
export const autoQueryControls = [
|
||||
'datasource',
|
||||
'viz_type',
|
||||
];
|
||||
|
||||
const defaultControls = Object.assign({}, controls);
|
||||
Object.keys(controls).forEach((f) => {
|
||||
defaultControls[f].value = controls[f].default;
|
||||
});
|
||||
|
||||
const defaultState = {
|
||||
controls: defaultControls,
|
||||
form_data: getFormDataFromControls(defaultControls),
|
||||
};
|
||||
|
||||
export { defaultControls, defaultState };
|
||||
801
superset/assets/javascripts/explore/stores/visTypes.js
Normal file
801
superset/assets/javascripts/explore/stores/visTypes.js
Normal file
@@ -0,0 +1,801 @@
|
||||
export const sections = {
|
||||
druidTimeSeries: {
|
||||
label: 'Time',
|
||||
description: 'Time related form attributes',
|
||||
controlSetRows: [
|
||||
['granularity', 'druid_time_origin'],
|
||||
['since', 'until'],
|
||||
],
|
||||
},
|
||||
datasourceAndVizType: {
|
||||
label: 'Datasource & Chart Type',
|
||||
controlSetRows: [
|
||||
['datasource'],
|
||||
['viz_type'],
|
||||
['slice_id', 'cache_timeout'],
|
||||
],
|
||||
},
|
||||
sqlaTimeSeries: {
|
||||
label: 'Time',
|
||||
description: 'Time related form attributes',
|
||||
controlSetRows: [
|
||||
['granularity_sqla', 'time_grain_sqla'],
|
||||
['since', 'until'],
|
||||
],
|
||||
},
|
||||
sqlClause: {
|
||||
label: 'SQL',
|
||||
controlSetRows: [
|
||||
['where'],
|
||||
['having'],
|
||||
],
|
||||
description: 'This section exposes ways to include snippets of SQL in your query',
|
||||
},
|
||||
NVD3TimeSeries: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metrics'],
|
||||
['groupby'],
|
||||
['limit', 'timeseries_limit_metric'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Advanced Analytics',
|
||||
description: 'This section contains options ' +
|
||||
'that allow for advanced analytical post processing ' +
|
||||
'of query results',
|
||||
controlSetRows: [
|
||||
['rolling_type', 'rolling_periods'],
|
||||
['time_compare'],
|
||||
['num_period_compare', 'period_ratio_type'],
|
||||
['resample_how', 'resample_rule'],
|
||||
['resample_fillmethod'],
|
||||
],
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
label: 'Filters',
|
||||
description: 'Filters are defined using comma delimited strings as in <US,FR,Other>' +
|
||||
'Leave the value control empty to filter empty strings or nulls' +
|
||||
'For filters with comma in values, wrap them in single quotes' +
|
||||
"as in <NY, 'Tahoe, CA', DC>",
|
||||
controlSetRows: [['filters']],
|
||||
},
|
||||
{
|
||||
label: 'Result Filters',
|
||||
description: 'The filters to apply after post-aggregation.' +
|
||||
'Leave the value control empty to filter empty strings or nulls',
|
||||
controlSetRows: [['having_filters']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const visTypes = {
|
||||
dist_bar: {
|
||||
label: 'Distribution - Bar Chart',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['metrics'],
|
||||
['groupby'],
|
||||
['columns'],
|
||||
['row_limit'],
|
||||
['show_legend', 'show_bar_value'],
|
||||
['bar_stacked', 'order_bars'],
|
||||
['y_axis_format', 'bottom_margin'],
|
||||
['x_axis_label', 'y_axis_label'],
|
||||
['reduce_x_ticks', 'contribution'],
|
||||
['show_controls'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
groupby: {
|
||||
label: 'Series',
|
||||
},
|
||||
columns: {
|
||||
label: 'Breakdowns',
|
||||
description: 'Defines how each series is broken down',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
pie: {
|
||||
label: 'Pie Chart',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metrics', 'groupby'],
|
||||
['limit'],
|
||||
['pie_label_type'],
|
||||
['donut', 'show_legend'],
|
||||
['labels_outside'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
line: {
|
||||
label: 'Time Series - Line Chart',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
sections.NVD3TimeSeries[0],
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['show_brush', 'show_legend'],
|
||||
['rich_tooltip', 'y_axis_zero'],
|
||||
['y_log_scale', 'contribution'],
|
||||
['show_markers', 'x_axis_showminmax'],
|
||||
['line_interpolation'],
|
||||
['x_axis_format', 'y_axis_format'],
|
||||
['x_axis_label', 'y_axis_label'],
|
||||
],
|
||||
},
|
||||
sections.NVD3TimeSeries[1],
|
||||
],
|
||||
},
|
||||
|
||||
dual_line: {
|
||||
label: 'Time Series - Dual Axis Line Chart',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['x_axis_format'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Y Axis 1',
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['y_axis_format'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Y Axis 2',
|
||||
controlSetRows: [
|
||||
['metric_2'],
|
||||
['y_axis_2_format'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
metric: {
|
||||
label: 'Left Axis Metric',
|
||||
description: 'Choose a metric for left axis',
|
||||
},
|
||||
y_axis_format: {
|
||||
label: 'Left Axis Format',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
bar: {
|
||||
label: 'Time Series - Bar Chart',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
sections.NVD3TimeSeries[0],
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['show_brush', 'show_legend', 'show_bar_value'],
|
||||
['rich_tooltip', 'y_axis_zero'],
|
||||
['y_log_scale', 'contribution'],
|
||||
['x_axis_format', 'y_axis_format'],
|
||||
['line_interpolation', 'bar_stacked'],
|
||||
['x_axis_showminmax', 'bottom_margin'],
|
||||
['x_axis_label', 'y_axis_label'],
|
||||
['reduce_x_ticks', 'show_controls'],
|
||||
],
|
||||
},
|
||||
sections.NVD3TimeSeries[1],
|
||||
],
|
||||
},
|
||||
|
||||
compare: {
|
||||
label: 'Time Series - Percent Change',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
sections.NVD3TimeSeries[0],
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['x_axis_format', 'y_axis_format'],
|
||||
],
|
||||
},
|
||||
sections.NVD3TimeSeries[1],
|
||||
],
|
||||
},
|
||||
|
||||
area: {
|
||||
label: 'Time Series - Stacked',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
sections.NVD3TimeSeries[0],
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['show_brush', 'show_legend'],
|
||||
['rich_tooltip', 'y_axis_zero'],
|
||||
['y_log_scale', 'contribution'],
|
||||
['x_axis_format', 'y_axis_format'],
|
||||
['x_axis_showminmax', 'show_controls'],
|
||||
['line_interpolation', 'stacked_style'],
|
||||
],
|
||||
},
|
||||
sections.NVD3TimeSeries[1],
|
||||
],
|
||||
},
|
||||
|
||||
table: {
|
||||
label: 'Table View',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: 'GROUP BY',
|
||||
description: 'Use this section if you want a query that aggregates',
|
||||
controlSetRows: [
|
||||
['groupby', 'metrics'],
|
||||
['include_time'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'NOT GROUPED BY',
|
||||
description: 'Use this section if you want to query atomic rows',
|
||||
controlSetRows: [
|
||||
['all_columns'],
|
||||
['order_by_cols'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Options',
|
||||
controlSetRows: [
|
||||
['table_timestamp_format'],
|
||||
['row_limit', 'page_length'],
|
||||
['include_search', 'table_filter'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
metrics: {
|
||||
validators: [],
|
||||
},
|
||||
time_grain_sqla: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
markup: {
|
||||
label: 'Markup',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['markup_type'],
|
||||
['code'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
pivot_table: {
|
||||
label: 'Pivot Table',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['groupby', 'columns'],
|
||||
['metrics', 'pandas_aggfunc'],
|
||||
['number_format'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
separator: {
|
||||
label: 'Separator',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['code'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
code: {
|
||||
default: '####Section Title\n' +
|
||||
'A paragraph describing the section' +
|
||||
'of the dashboard, right before the separator line ' +
|
||||
'\n\n' +
|
||||
'---------------',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
word_cloud: {
|
||||
label: 'Word Cloud',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['series', 'metric', 'limit'],
|
||||
['size_from', 'size_to'],
|
||||
['rotation'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
treemap: {
|
||||
label: 'Treemap',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metrics'],
|
||||
['groupby'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['treemap_ratio'],
|
||||
['number_format'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
cal_heatmap: {
|
||||
label: 'Calendar Heatmap',
|
||||
requiresTime: true,
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['domain_granularity'],
|
||||
['subdomain_granularity'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
box_plot: {
|
||||
label: 'Box Plot',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metrics'],
|
||||
['groupby', 'limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['whisker_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
bubble: {
|
||||
label: 'Bubble Chart',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['series', 'entity'],
|
||||
['x', 'y'],
|
||||
['size', 'limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['x_log_scale', 'y_log_scale'],
|
||||
['show_legend'],
|
||||
['max_bubble_size'],
|
||||
['x_axis_label', 'y_axis_label'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
bullet: {
|
||||
label: 'Bullet Chart',
|
||||
requiresTime: false,
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['ranges', 'range_labels'],
|
||||
['markers', 'marker_labels'],
|
||||
['marker_lines', 'marker_line_labels'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
big_number: {
|
||||
label: 'Big Number with Trendline',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['compare_lag'],
|
||||
['compare_suffix'],
|
||||
['y_axis_format'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
y_axis_format: {
|
||||
label: 'Number format',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
big_number_total: {
|
||||
label: 'Big Number',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['subheader'],
|
||||
['y_axis_format'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
y_axis_format: {
|
||||
label: 'Number format',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
histogram: {
|
||||
label: 'Histogram',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['all_columns_x'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Histogram Options',
|
||||
controlSetRows: [
|
||||
['link_length'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
all_columns_x: {
|
||||
label: 'Numeric Column',
|
||||
description: 'Select the numeric column to draw the histogram',
|
||||
},
|
||||
link_length: {
|
||||
label: 'No of Bins',
|
||||
description: 'Select number of bins for the histogram',
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
sunburst: {
|
||||
label: 'Sunburst',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['groupby'],
|
||||
['metric', 'secondary_metric'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
metric: {
|
||||
label: 'Primary Metric',
|
||||
description: 'The primary metric is used to define the arc segment sizes',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Secondary Metric',
|
||||
description: 'This secondary metric is used to ' +
|
||||
'define the color as a ratio against the primary metric. ' +
|
||||
'If the two metrics match, color is mapped level groups',
|
||||
},
|
||||
groupby: {
|
||||
label: 'Hierarchy',
|
||||
description: 'This defines the level of the hierarchy',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
sankey: {
|
||||
label: 'Sankey',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['groupby'],
|
||||
['metric'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
groupby: {
|
||||
label: 'Source / Target',
|
||||
description: 'Choose a source and a target',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
directed_force: {
|
||||
label: 'Directed Force Layout',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['groupby'],
|
||||
['metric'],
|
||||
['row_limit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Force Layout',
|
||||
controlSetRows: [
|
||||
['link_length'],
|
||||
['charge'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
groupby: {
|
||||
label: 'Source / Target',
|
||||
description: 'Choose a source and a target',
|
||||
},
|
||||
},
|
||||
},
|
||||
country_map: {
|
||||
label: 'Country Map',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['select_country'],
|
||||
['entity'],
|
||||
['metric'],
|
||||
['linear_color_scheme'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
entity: {
|
||||
label: 'ISO 3166-1 codes of region/province/department',
|
||||
description: "It's ISO 3166-1 of your region/province/department in your table. (see documentation for list of ISO 3166-1)",
|
||||
},
|
||||
metric: {
|
||||
label: 'Metric',
|
||||
description: 'Metric to display bottom title',
|
||||
},
|
||||
},
|
||||
},
|
||||
world_map: {
|
||||
label: 'World Map',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['entity'],
|
||||
['country_fieldtype'],
|
||||
['metric'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bubbles',
|
||||
controlSetRows: [
|
||||
['show_bubbles'],
|
||||
['secondary_metric'],
|
||||
['max_bubble_size'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
entity: {
|
||||
label: 'Country Control',
|
||||
description: '3 letter code of the country',
|
||||
},
|
||||
metric: {
|
||||
label: 'Metric for color',
|
||||
description: 'Metric that defines the color of the country',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Bubble size',
|
||||
description: 'Metric that defines the size of the bubble',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
filter_box: {
|
||||
label: 'Filter Box',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['date_filter', 'instant_filtering'],
|
||||
['groupby'],
|
||||
['metric'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
groupby: {
|
||||
label: 'Filter controls',
|
||||
description: 'The controls you want to filter on',
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
iframe: {
|
||||
label: 'iFrame',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['url'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
para: {
|
||||
label: 'Parallel Coordinates',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['series'],
|
||||
['metrics'],
|
||||
['secondary_metric'],
|
||||
['limit'],
|
||||
['show_datatable', 'include_series'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
heatmap: {
|
||||
label: 'Heatmap',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: 'Axis & Metrics',
|
||||
controlSetRows: [
|
||||
['all_columns_x'],
|
||||
['all_columns_y'],
|
||||
['metric'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Heatmap Options',
|
||||
controlSetRows: [
|
||||
['linear_color_scheme'],
|
||||
['xscale_interval', 'yscale_interval'],
|
||||
['canvas_image_rendering'],
|
||||
['normalize_across'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
horizon: {
|
||||
label: 'Horizon',
|
||||
controlPanelSections: [
|
||||
sections.NVD3TimeSeries[0],
|
||||
{
|
||||
label: 'Chart Options',
|
||||
controlSetRows: [
|
||||
['series_height', 'horizon_color_scale'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
mapbox: {
|
||||
label: 'Mapbox',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
controlSetRows: [
|
||||
['all_columns_x', 'all_columns_y'],
|
||||
['clustering_radius'],
|
||||
['row_limit'],
|
||||
['groupby'],
|
||||
['render_while_dragging'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Points',
|
||||
controlSetRows: [
|
||||
['point_radius'],
|
||||
['point_radius_unit'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Labelling',
|
||||
controlSetRows: [
|
||||
['mapbox_label'],
|
||||
['pandas_aggfunc'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Visual Tweaks',
|
||||
controlSetRows: [
|
||||
['mapbox_style'],
|
||||
['global_opacity'],
|
||||
['mapbox_color'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Viewport',
|
||||
controlSetRows: [
|
||||
['viewport_longitude'],
|
||||
['viewport_latitude'],
|
||||
['viewport_zoom'],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
all_columns_x: {
|
||||
label: 'Longitude',
|
||||
description: 'Column containing longitude data',
|
||||
},
|
||||
all_columns_y: {
|
||||
label: 'Latitude',
|
||||
description: 'Column containing latitude data',
|
||||
},
|
||||
pandas_aggfunc: {
|
||||
label: 'Cluster label aggregator',
|
||||
description: 'Aggregate function applied to the list of points ' +
|
||||
'in each cluster to produce the cluster label.',
|
||||
},
|
||||
rich_tooltip: {
|
||||
label: 'Tooltip',
|
||||
description: 'Show a tooltip when hovering over points and clusters ' +
|
||||
'describing the label',
|
||||
},
|
||||
groupby: {
|
||||
description: 'One or many controls to group by. If grouping, latitude ' +
|
||||
'and longitude columns must be present.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default visTypes;
|
||||
|
||||
export function sectionsToRender(vizType, datasourceType) {
|
||||
const viz = visTypes[vizType];
|
||||
return [].concat(
|
||||
sections.datasourceAndVizType,
|
||||
datasourceType === 'table' ? sections.sqlaTimeSeries : sections.druidTimeSeries,
|
||||
viz.controlPanelSections,
|
||||
datasourceType === 'table' ? sections.sqlClause : [],
|
||||
datasourceType === 'table' ? sections.filters[0] : sections.filters,
|
||||
);
|
||||
}
|
||||
32
superset/assets/javascripts/explore/validators.js
Normal file
32
superset/assets/javascripts/explore/validators.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Reusable validator functions used in controls definitions
|
||||
*
|
||||
* validator functions receive the v and the configuration of the control
|
||||
* as arguments and return something that evals to false if v is valid,
|
||||
* and an error message if not valid.
|
||||
* */
|
||||
|
||||
export function numeric(v) {
|
||||
if (v && isNaN(v)) {
|
||||
return 'is expected to be a number';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function integer(v) {
|
||||
if (v && (isNaN(v) || parseInt(v, 10) !== +(v))) {
|
||||
return 'is expected to be an integer';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function nonEmpty(v) {
|
||||
if (
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
v === '' ||
|
||||
(Array.isArray(v) && v.length === 0)
|
||||
) {
|
||||
return 'cannot be empty';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user