[WiP] Deprecate Explore v1 (#2064)

* Simplifying the viz interface (#2005)

* Working on dashes

* Making this a collaborative branch

* Fixing some bugs

* Fixing bugs

* More improvements

* Add datasource back in bootstrap data

* Decent state

* Linting

* Moving forward

* Some more linting

* Fix the timer

* Triggering events through state

* Lingint

* Put filters in an array instead of flt strings (#2090)

* Put filters in an array instead of flt strings

* Remove query_filter(), put opChoices into Filter

* Update version_info.json

* Fix migrations

* More renderTrigger=true

* Fixing bugs

* Working on standalone

* getting standalone to work

* Fixed forcedHeight for standalone =view

* Linting

* Get save slice working in v2 (#2106)

* Filter bugfix

* Fixing empty series limit bug

* Fixed dashboard view

* Fixing short urls

* Only allow owners to overwrite slice (#2142)

* Raise exception when date range is wrong

* Only allow owner to overwrite a slice

* Fix tests for deprecate v1 (#2140)

* Fixed tests for control panels container and filters

* Fixed python tests for explorev2

* Fix linting errors

* Add in stop button during slice querying/rendering (#2121)

* Add in stop button during slice querying/rendering

* Abort ajax request on stop

* Adding missing legacy module

* Removing select2.sortable.js because of license

* Allow query to display while slice is loading (#2100)

* Allow query to display while slice is loading

* Put latestQueryFormData in store

* Reorganized query function, got rid of tu[le return values

* Merging migrations

* Wrapping up shortner migration

* Fixing tests

* Add folder creation to syncBackend

* Fixing edit URL in explore view

* Fix look of Stop button

* Adding syntax highlighting to query modal

* Fix cast_form_data and flase checkbox on dash

* Bugfix

* Going deeper

* Fix filtering

* Deleing invalid filters when changing datasource

* Minor adjustments

* Fixing calendar heatmap examples

* Moving edit datasource button to header's right side

* Fixing mapbox example

* Show stack trace when clicking alert

* Adding npm sync-backend command to build instruction

* Bumping up JS dependencies

* rm dep on select2

* Fix py3 urlparse

* rm superset-select2.js

* Improving migration scripts

* Bugfixes on staging

* Fixing Markup viz
This commit is contained in:
Maxime Beauchemin 2017-02-16 17:28:35 -08:00 committed by GitHub
parent 3b023e5eaa
commit 0cc8eff1c3
82 changed files with 4018 additions and 3867 deletions

View File

@ -34,5 +34,4 @@ install:
- pip install --upgrade pip - pip install --upgrade pip
- pip install tox tox-travis - pip install tox tox-travis
- rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION - rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION
- npm install
script: tox -e $TOX_ENV script: tox -e $TOX_ENV

View File

@ -211,6 +211,9 @@ following commands. The `dev` flag will keep the npm script running and
re-run it upon any changes within the assets directory. re-run it upon any changes within the assets directory.
``` ```
# Copies a conf file from the frontend to the backend
npm run sync-backend
# Compiles the production / optimized js & css # Compiles the production / optimized js & css
npm run prod npm run prod

View File

@ -7,7 +7,6 @@ rm -f .coverage
export SUPERSET_CONFIG=tests.superset_test_config export SUPERSET_CONFIG=tests.superset_test_config
set -e set -e
superset/bin/superset db upgrade superset/bin/superset db upgrade
superset/bin/superset db upgrade # running twice on purpose as a test
superset/bin/superset version -v superset/bin/superset version -v
python setup.py nosetests python setup.py nosetests
coveralls coveralls

View File

@ -6,6 +6,7 @@ from __future__ import unicode_literals
import logging import logging
import os import os
import json
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from flask import Flask, redirect from flask import Flask, redirect
@ -21,6 +22,10 @@ from superset import utils
APP_DIR = os.path.dirname(__file__) APP_DIR = os.path.dirname(__file__)
CONFIG_MODULE = os.environ.get('SUPERSET_CONFIG', 'superset.config') CONFIG_MODULE = os.environ.get('SUPERSET_CONFIG', 'superset.config')
with open(APP_DIR + '/static/assets/backendSync.json', 'r') as f:
frontend_config = json.load(f)
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(CONFIG_MODULE) app.config.from_object(CONFIG_MODULE)
conf = app.config conf = app.config

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import cx from 'classnames';
import TooltipWrapper from './TooltipWrapper'; import TooltipWrapper from './TooltipWrapper';
const propTypes = { const propTypes = {
sliceId: PropTypes.string.isRequired, sliceId: PropTypes.number.isRequired,
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
isStarred: PropTypes.bool.isRequired, isStarred: PropTypes.bool.isRequired,
}; };

View File

@ -13,7 +13,6 @@ import Header from './components/Header';
require('bootstrap'); require('bootstrap');
require('../../stylesheets/dashboard.css'); require('../../stylesheets/dashboard.css');
require('../superset-select2.js');
export function getInitialState(dashboardData, context) { export function getInitialState(dashboardData, context) {
const dashboard = Object.assign({ context }, utils.controllerInterface, dashboardData); const dashboard = Object.assign({ context }, utils.controllerInterface, dashboardData);
@ -83,9 +82,6 @@ function initDashboardView(dashboard) {
); );
$('div.grid-container').css('visibility', 'visible'); $('div.grid-container').css('visibility', 'visible');
$('.select2').select2({
dropdownAutoWidth: true,
});
$('div.widget').click(function (e) { $('div.widget').click(function (e) {
const $this = $(this); const $this = $(this);
const $target = $(e.target); const $target = $(e.target);
@ -165,9 +161,7 @@ export function dashboardContainer(dashboard) {
} }
}, },
effectiveExtraFilters(sliceId) { effectiveExtraFilters(sliceId) {
// Summarized filter, not defined by sliceId const f = [];
// returns k=field, v=array of values
const f = {};
const immuneSlices = this.metadata.filter_immune_slices || []; const immuneSlices = this.metadata.filter_immune_slices || [];
if (sliceId && immuneSlices.includes(sliceId)) { if (sliceId && immuneSlices.includes(sliceId)) {
// The slice is immune to dashboard fiterls // The slice is immune to dashboard fiterls
@ -185,7 +179,11 @@ export function dashboardContainer(dashboard) {
for (const filteringSliceId in this.filters) { for (const filteringSliceId in this.filters) {
for (const field in this.filters[filteringSliceId]) { for (const field in this.filters[filteringSliceId]) {
if (!immuneToFields.includes(field)) { if (!immuneToFields.includes(field)) {
f[field] = this.filters[filteringSliceId][field]; f.push({
col: field,
op: 'in',
val: this.filters[filteringSliceId][field],
});
} }
} }
} }

View File

@ -98,7 +98,7 @@ class GridLayout extends React.Component {
id={'slice_' + slice.slice_id} id={'slice_' + slice.slice_id}
key={slice.slice_id} key={slice.slice_id}
data-slice-id={slice.slice_id} data-slice-id={slice.slice_id}
className={`widget ${slice.viz_name}`} className={`widget ${slice.form_data.viz_type}`}
> >
<SliceCell <SliceCell
slice={slice} slice={slice}

View File

@ -24,7 +24,7 @@ class Header extends React.PureComponent {
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} /> <span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
</h1> </h1>
</div> </div>
<div className="pull-right"> <div className="pull-right" style={{ marginTop: '35px' }}>
{!this.props.dashboard.context.standalone_mode && {!this.props.dashboard.context.standalone_mode &&
<Controls dashboard={dashboard} /> <Controls dashboard={dashboard} />
} }

View File

@ -67,13 +67,13 @@ function SliceCell({ expandedSlices, removeSlice, slice }) {
</div> </div>
<div className="row chart-container"> <div className="row chart-container">
<input type="hidden" value="false" /> <input type="hidden" value="false" />
<div id={slice.token} className="token col-md-12"> <div id={'token_' + slice.slice_id} className="token col-md-12">
<img <img
src="/static/assets/images/loading.gif" src="/static/assets/images/loading.gif"
className="loading" className="loading"
alt="loading" alt="loading"
/> />
<div className="slice_container" id={slice.token + '_con'}></div> <div className="slice_container" id={'con_' + slice.slice_id}></div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,25 +0,0 @@
import React, { PropTypes } from 'react';
import ModalTrigger from './../../components/ModalTrigger';
const propTypes = {
query: PropTypes.string,
};
const defaultProps = {
query: '',
};
export default function DisplayQueryButton({ query }) {
const modalBody = (<pre>{query}</pre>);
return (
<ModalTrigger
isButton
triggerNode={<span>Query</span>}
modalTitle="Query"
modalBody={modalBody}
/>
);
}
DisplayQueryButton.propTypes = propTypes;
DisplayQueryButton.defaultProps = defaultProps;

View File

@ -1,46 +0,0 @@
import React, { PropTypes } from 'react';
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.isRequired,
query: PropTypes.string,
};
export default function ExploreActionButtons({ canDownload, slice, query }) {
const exportToCSVClasses = cx('btn btn-default btn-sm', {
'disabled disabledButton': !canDownload,
});
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"
>
<i className="fa fa-file-code-o"></i> .json
</a>
<a
href={slice.data.csv_endpoint}
className={exportToCSVClasses}
title="Export to .csv format"
target="_blank"
>
<i className="fa fa-file-text-o"></i> .csv
</a>
<DisplayQueryButton query={query} />
</div>
);
}
ExploreActionButtons.propTypes = propTypes;

View File

@ -1,403 +0,0 @@
// Javascript for the explorer page
// Init explorer view -> load vis dependencies -> read data (from dynamic html) -> render slice
// nb: to add a new vis, you must also add a Python fn in viz.py
//
// js
const $ = window.$ = require('jquery');
const px = require('./../modules/superset.js');
const utils = require('./../modules/utils.js');
const jQuery = window.jQuery = require('jquery'); // eslint-disable-line
import React from 'react';
import ReactDOM from 'react-dom';
import QueryAndSaveBtns from './components/QueryAndSaveBtns.jsx';
import ExploreActionButtons from './components/ExploreActionButtons.jsx';
require('jquery-ui');
$.widget.bridge('uitooltip', $.ui.tooltip); // Shutting down jq-ui tooltips
require('bootstrap');
require('./../superset-select2.js');
// css
require('../../vendor/pygments.css');
require('../../stylesheets/explore.css');
let slice;
const getPanelClass = function (fieldPrefix) {
return (fieldPrefix === 'flt' ? 'filter' : 'having') + '_panel';
};
function prepForm() {
// Assigning the right id to form elements in filters
const fixId = function ($filter, fieldPrefix, i) {
$filter.attr('id', function () {
return fieldPrefix + '_' + i;
});
['col', 'op', 'eq'].forEach(function (fieldMiddle) {
const fieldName = fieldPrefix + '_' + fieldMiddle;
$filter.find('[id^=' + fieldName + '_]')
.attr('id', function () {
return fieldName + '_' + i;
})
.attr('name', function () {
return fieldName + '_' + i;
});
});
};
['flt', 'having'].forEach(function (fieldPrefix) {
let i = 1;
$('#' + getPanelClass(fieldPrefix) + ' #filters > div').each(function () {
fixId($(this), fieldPrefix, i);
i++;
});
});
}
function query(forceUpdate, pushState) {
let force = forceUpdate;
if (force === undefined) {
force = false;
}
$('.query-and-save button').attr('disabled', 'disabled');
if (force) { // Don't hide the alert message when the page is just loaded
$('div.alert').remove();
}
$('#is_cached').hide();
prepForm();
if (pushState !== false) {
// update the url after prepForm() fix the field ids
history.pushState({}, document.title, slice.querystring());
}
slice.container.html('');
slice.render(force);
}
function saveSlice() {
const action = $('input[name=rdo_save]:checked').val();
if (action === 'saveas') {
const sliceName = $('input[name=new_slice_name]').val();
if (sliceName === '') {
utils.showModal({
title: 'Error',
body: 'You must pick a name for the new slice',
});
return;
}
document.getElementById('slice_name').value = sliceName;
}
const addToDash = $('input[name=addToDash]:checked').val();
if (addToDash === 'existing' && $('#save_to_dashboard_id').val() === '') {
utils.showModal({
title: 'Error',
body: 'You must pick an existing dashboard',
});
return;
} else if (addToDash === 'new' && $('input[name=new_dashboard_name]').val() === '') {
utils.showModal({
title: 'Error',
body: 'Please enter a name for the new dashboard',
});
return;
}
$('#action').val(action);
prepForm();
$('#query').submit();
}
function initExploreView() {
function getCollapsedFieldsets() {
let collapsedFieldsets = $('#collapsedFieldsets').val();
if (collapsedFieldsets !== undefined && collapsedFieldsets !== '') {
collapsedFieldsets = collapsedFieldsets.split('||');
} else {
collapsedFieldsets = [];
}
return collapsedFieldsets;
}
function toggleFieldset(legend, animation) {
const parent = legend.parent();
const fieldset = parent.find('.legend_label').text();
const collapsedFieldsets = getCollapsedFieldsets();
let index;
if (parent.hasClass('collapsed')) {
if (animation) {
parent.find('.panel-body').slideDown();
} else {
parent.find('.panel-body').show();
}
parent.removeClass('collapsed');
parent.find('span.collapser').text('[-]');
// removing from array, js is overcomplicated
index = collapsedFieldsets.indexOf(fieldset);
if (index !== -1) {
collapsedFieldsets.splice(index, 1);
}
} else { // not collapsed
if (animation) {
parent.find('.panel-body').slideUp();
} else {
parent.find('.panel-body').hide();
}
parent.addClass('collapsed');
parent.find('span.collapser').text('[+]');
index = collapsedFieldsets.indexOf(fieldset);
if (index === -1 && fieldset !== '' && fieldset !== undefined) {
collapsedFieldsets.push(fieldset);
}
}
$('#collapsedFieldsets').val(collapsedFieldsets.join('||'));
}
px.initFavStars();
$('#viz_type').change(function () {
$('#query').submit();
});
$('#datasource_id').change(function () {
window.location = $(this).find('option:selected').attr('url');
});
const collapsedFieldsets = getCollapsedFieldsets();
for (let i = 0; i < collapsedFieldsets.length; i++) {
toggleFieldset($('legend:contains("' + collapsedFieldsets[i] + '")'), false);
}
function formatViz(viz) {
const url = `/static/assets/images/viz_thumbnails/${viz.id}.png`;
const noImg = '/static/assets/images/noimg.png';
return $(
`<img class="viz-thumb-option" src="${url}" onerror="this.src='${noImg}';">` +
`<span>${viz.text}</span>`
);
}
$('.select2').select2({
dropdownAutoWidth: true,
});
$('.select2Sortable').select2({
dropdownAutoWidth: true,
});
$('.select2-with-images').select2({
dropdownAutoWidth: true,
dropdownCssClass: 'bigdrop',
formatResult: formatViz,
});
$('.select2Sortable').select2Sortable({
bindOrder: 'sortableStop',
});
$('form').show();
$('[data-toggle="tooltip"]').tooltip({ container: 'body' });
$('.ui-helper-hidden-accessible').remove(); // jQuery-ui 1.11+ creates a div for every tooltip
function addFilter(i, fieldPrefix) {
const cp = $('#' + fieldPrefix + '0').clone();
$(cp).appendTo('#' + getPanelClass(fieldPrefix) + ' #filters');
$(cp).show();
if (i !== undefined) {
$(cp).find('#' + fieldPrefix + '_eq_0').val(px.getParam(fieldPrefix + '_eq_' + i));
$(cp).find('#' + fieldPrefix + '_op_0').val(px.getParam(fieldPrefix + '_op_' + i));
$(cp).find('#' + fieldPrefix + '_col_0').val(px.getParam(fieldPrefix + '_col_' + i));
}
$(cp).find('select').select2();
$(cp).find('.remove').click(function () {
$(this)
.parent()
.parent()
.remove();
});
}
function setFilters() {
['flt', 'having'].forEach(function (prefix) {
for (let i = 1; i < 10; i++) {
const col = px.getParam(prefix + '_col_' + i);
if (col !== '') {
addFilter(i, prefix);
}
}
});
}
setFilters();
$(window).bind('popstate', function () {
// Browser back button
const returnLocation = history.location || document.location;
// Could do something more lightweight here, but we're not optimizing
// for the use of the back button anyways
returnLocation.reload();
});
$('#filter_panel #plus').click(function () {
addFilter(undefined, 'flt');
});
$('#having_panel #plus').click(function () {
addFilter(undefined, 'having');
});
function createChoices(term, data) {
const filtered = $(data).filter(function () {
return this.text.localeCompare(term) === 0;
});
if (filtered.length === 0) {
return {
id: term,
text: term,
};
}
return {};
}
function initSelectionToValue(element, callback) {
callback({
id: element.val(),
text: element.val(),
});
}
$('.select2_freeform').each(function () {
const parent = $(this).parent();
const name = $(this).attr('name');
const l = [];
let selected = '';
for (let i = 0; i < this.options.length; i++) {
l.push({
id: this.options[i].value,
text: this.options[i].text,
});
if (this.options[i].selected) {
selected = this.options[i].value;
}
}
parent.append(
`<input class="${$(this).attr('class')}" ` +
`name="${name}" type="text" value="${selected}">`
);
$(`input[name='${name}']`).select2({
createSearchChoice: createChoices,
initSelection: initSelectionToValue,
dropdownAutoWidth: true,
multiple: false,
data: l,
});
$(this).remove();
});
function prepSaveDialog() {
const setButtonsState = function () {
const addToDash = $('input[name=addToDash]:checked').val();
if (addToDash === 'existing' || addToDash === 'new') {
$('.gotodash').removeAttr('disabled');
} else {
$('.gotodash').prop('disabled', true);
}
};
const url = '/dashboardmodelviewasync/api/read?_flt_0_owners=' + $('#userid').val();
$.get(url, function (data) {
const choices = [];
for (let i = 0; i < data.pks.length; i++) {
choices.push({ id: data.pks[i], text: data.result[i].dashboard_title });
}
$('#save_to_dashboard_id').select2({
data: choices,
dropdownAutoWidth: true,
}).on('select2-selecting', function () {
$('#addToDash_existing').prop('checked', true);
setButtonsState();
});
});
$('input[name=addToDash]').change(setButtonsState);
$("input[name='new_dashboard_name']").on('focus', function () {
$('#add_to_new_dash').prop('checked', true);
setButtonsState();
});
$("input[name='new_slice_name']").on('focus', function () {
$('#save_as_new').prop('checked', true);
setButtonsState();
});
$('#btn_modal_save').on('click', () => saveSlice());
$('#btn_modal_save_goto_dash').click(() => {
document.getElementById('goto_dash').value = 'true';
saveSlice();
});
}
prepSaveDialog();
}
function renderExploreActions() {
const exploreActionsEl = document.getElementById('js-explore-actions');
ReactDOM.render(
<ExploreActionButtons
canDownload={exploreActionsEl.getAttribute('data-can-download')}
slice={slice}
query={slice.viewSqlQuery}
/>,
exploreActionsEl
);
}
function initComponents() {
const queryAndSaveBtnsEl = document.getElementById('js-query-and-save-btns');
ReactDOM.render(
<QueryAndSaveBtns
canAdd={queryAndSaveBtnsEl.getAttribute('data-can-add')}
onQuery={() => query(true)}
/>,
queryAndSaveBtnsEl
);
renderExploreActions();
}
let exploreController = {
type: 'slice',
done: (sliceObj) => {
slice = sliceObj;
renderExploreActions();
const cachedSelector = $('#is_cached');
if (slice.data !== undefined && slice.data.is_cached) {
cachedSelector
.attr(
'title',
`Served from data cached at ${slice.data.cached_dttm}. Click [Query] to force refresh`)
.show()
.tooltip('fixTitle');
} else {
cachedSelector.hide();
}
},
error: (sliceObj) => {
slice = sliceObj;
renderExploreActions();
},
};
exploreController = Object.assign({}, utils.controllerInterface, exploreController);
$(document).ready(function () {
const data = $('.slice').data('slice');
initExploreView();
slice = px.Slice(data, exploreController);
// call vis render method, which issues ajax
// calls render on the slice for the first time
query(false, false);
slice.bindResizeToWindowResize();
initComponents();
});

View File

@ -13,42 +13,88 @@ export function setDatasource(datasource) {
return { type: SET_DATASOURCE, datasource }; return { type: SET_DATASOURCE, datasource };
} }
export const FETCH_STARTED = 'FETCH_STARTED'; export const SET_DATASOURCES = 'SET_DATASOURCES';
export function fetchStarted() { export function setDatasources(datasources) {
return { type: FETCH_STARTED }; return { type: SET_DATASOURCES, datasources };
} }
export const FETCH_SUCCEEDED = 'FETCH_SUCCEEDED'; export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
export function fetchSucceeded() { export function fetchDatasourceStarted() {
return { type: FETCH_SUCCEEDED }; return { type: FETCH_DATASOURCE_STARTED };
} }
export const FETCH_FAILED = 'FETCH_FAILED'; export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED';
export function fetchFailed(error) { export function fetchDatasourceSucceeded() {
return { type: FETCH_FAILED, error }; return { type: FETCH_DATASOURCE_SUCCEEDED };
} }
export function fetchDatasourceMetadata(datasourceId, datasourceType) { 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 resetFields() {
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) { return function (dispatch) {
dispatch(fetchStarted()); dispatch(fetchDatasourceStarted());
const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`;
$.ajax({
type: 'GET',
url,
success: (data) => {
dispatch(setDatasource(data));
dispatch(fetchDatasourceSucceeded());
dispatch(resetFields());
if (alsoTriggerQuery) {
dispatch(triggerQuery());
}
},
error(error) {
dispatch(fetchDatasourceFailed(error.responseJSON.error));
},
});
};
}
if (datasourceId) { export function fetchDatasources() {
const params = [`datasource_id=${datasourceId}`, `datasource_type=${datasourceType}`]; return function (dispatch) {
const url = '/superset/fetch_datasource_metadata?' + params.join('&'); dispatch(fetchDatasourcesStarted());
$.ajax({ const url = '/superset/datasources/';
type: 'GET', $.ajax({
url, type: 'GET',
success: (data) => { url,
dispatch(setDatasource(data)); success: (data) => {
dispatch(fetchSucceeded()); dispatch(setDatasources(data));
}, dispatch(fetchDatasourcesSucceeded());
error(error) { },
dispatch(fetchFailed(error.responseJSON.error)); error(error) {
}, dispatch(fetchDatasourcesFailed(error.responseJSON.error));
}); },
} else { });
dispatch(fetchFailed('Please select a datasource'));
}
}; };
} }
@ -85,8 +131,8 @@ export function setFieldValue(fieldName, value, validationErrors) {
} }
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED'; export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
export function chartUpdateStarted() { export function chartUpdateStarted(queryRequest) {
return { type: CHART_UPDATE_STARTED }; return { type: CHART_UPDATE_STARTED, queryRequest };
} }
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED'; export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
@ -94,6 +140,14 @@ export function chartUpdateSucceeded(queryResponse) {
return { type: CHART_UPDATE_SUCCEEDED, 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_FAILED = 'CHART_UPDATE_FAILED'; export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
export function chartUpdateFailed(queryResponse) { export function chartUpdateFailed(queryResponse) {
return { type: CHART_UPDATE_FAILED, queryResponse }; return { type: CHART_UPDATE_FAILED, queryResponse };
@ -126,7 +180,7 @@ export function fetchDashboardsSucceeded(choices) {
export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED'; export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
export function fetchDashboardsFailed(userId) { export function fetchDashboardsFailed(userId) {
return { type: FETCH_FAILED, userId }; return { type: FETCH_DASHBOARDS_FAILED, userId };
} }
export function fetchDashboards(userId) { export function fetchDashboards(userId) {
@ -177,12 +231,19 @@ export function updateChartStatus(status) {
export const RUN_QUERY = 'RUN_QUERY'; export const RUN_QUERY = 'RUN_QUERY';
export function runQuery(formData, datasourceType) { export function runQuery(formData, datasourceType) {
return function (dispatch) { return function (dispatch) {
dispatch(updateChartStatus('loading'));
const url = getExploreUrl(formData, datasourceType, 'json'); const url = getExploreUrl(formData, datasourceType, 'json');
$.getJSON(url, function (queryResponse) { const queryRequest = $.getJSON(url, function (queryResponse) {
dispatch(chartUpdateSucceeded(queryResponse)); dispatch(chartUpdateSucceeded(queryResponse));
}).fail(function (err) { }).fail(function (err) {
dispatch(chartUpdateFailed(err)); if (err.statusText !== 'abort') {
dispatch(chartUpdateFailed(err.responseJSON));
}
}); });
dispatch(chartUpdateStarted(queryRequest));
}; };
} }
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
export function renderTriggered() {
return { type: RENDER_TRIGGERED };
}

View File

@ -1,13 +1,15 @@
import $ from 'jquery'; import $ from 'jquery';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Panel, Alert } from 'react-bootstrap'; import { Panel, Alert, Collapse } from 'react-bootstrap';
import visMap from '../../../visualizations/main'; import visMap from '../../../visualizations/main';
import { d3format } from '../../modules/utils'; import { d3format } from '../../modules/utils';
import ExploreActionButtons from '../../explore/components/ExploreActionButtons'; import ExploreActionButtons from './ExploreActionButtons';
import FaveStar from '../../components/FaveStar'; import FaveStar from '../../components/FaveStar';
import TooltipWrapper from '../../components/TooltipWrapper'; import TooltipWrapper from '../../components/TooltipWrapper';
import Timer from '../../components/Timer'; import Timer from '../../components/Timer';
import { getExploreUrl } from '../exploreUtils';
import { getFormDataFromFields } from '../stores/store';
const CHART_STATUS_MAP = { const CHART_STATUS_MAP = {
failed: 'danger', failed: 'danger',
@ -17,20 +19,20 @@ const CHART_STATUS_MAP = {
const propTypes = { const propTypes = {
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
can_download: PropTypes.bool.isRequired,
slice_id: PropTypes.string.isRequired,
slice_name: PropTypes.string.isRequired,
viz_type: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
containerId: PropTypes.string.isRequired,
query: PropTypes.string,
column_formats: PropTypes.object,
chartStatus: PropTypes.string,
isStarred: PropTypes.bool.isRequired,
chartUpdateStartTime: PropTypes.number.isRequired,
chartUpdateEndTime: PropTypes.number,
alert: PropTypes.string, 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, table_name: PropTypes.string,
viz_type: PropTypes.string.isRequired,
formData: PropTypes.object,
latestQueryFormData: PropTypes.object,
}; };
class ChartContainer extends React.PureComponent { class ChartContainer extends React.PureComponent {
@ -38,14 +40,16 @@ class ChartContainer extends React.PureComponent {
super(props); super(props);
this.state = { this.state = {
selector: `#${props.containerId}`, selector: `#${props.containerId}`,
showStackTrace: false,
}; };
} }
renderViz() { renderViz() {
this.props.actions.renderTriggered();
const mockSlice = this.getMockedSliceObject(); const mockSlice = this.getMockedSliceObject();
this.setState({ mockSlice });
try { try {
visMap[this.props.viz_type](mockSlice, this.props.queryResponse); visMap[this.props.viz_type](mockSlice, this.props.queryResponse);
this.setState({ mockSlice });
} catch (e) { } catch (e) {
this.props.actions.chartRenderingFailed(e); this.props.actions.chartRenderingFailed(e);
} }
@ -53,8 +57,13 @@ class ChartContainer extends React.PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if ( if (
prevProps.queryResponse !== this.props.queryResponse || (
prevProps.height !== this.props.height 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.renderViz(); this.renderViz();
} }
@ -62,10 +71,15 @@ class ChartContainer extends React.PureComponent {
getMockedSliceObject() { getMockedSliceObject() {
const props = this.props; const props = this.props;
const getHeight = () => {
const headerHeight = this.props.standalone ? 0 : 100;
return parseInt(props.height, 10) - headerHeight;
};
return { return {
viewSqlQuery: props.query, viewSqlQuery: this.props.queryResponse.query,
containerId: props.containerId, containerId: props.containerId,
selector: this.state.selector, selector: this.state.selector,
formData: this.props.formData,
container: { container: {
html: (data) => { html: (data) => {
// this should be a callback to clear the contents of the slice container // this should be a callback to clear the contents of the slice container
@ -77,7 +91,7 @@ class ChartContainer extends React.PureComponent {
// should call callback to adjust height of chart // should call callback to adjust height of chart
$(this.state.selector).css(dim, size); $(this.state.selector).css(dim, size);
}, },
height: () => parseInt(props.height, 10) - 100, height: getHeight,
show: () => { }, show: () => { },
get: (n) => ($(this.state.selector).get(n)), get: (n) => ($(this.state.selector).get(n)),
find: (classname) => ($(this.state.selector).find(classname)), find: (classname) => ($(this.state.selector).find(classname)),
@ -85,7 +99,7 @@ class ChartContainer extends React.PureComponent {
width: () => this.chartContainerRef.getBoundingClientRect().width, width: () => this.chartContainerRef.getBoundingClientRect().width,
height: () => parseInt(props.height, 10) - 100, height: getHeight,
setFilter: () => { setFilter: () => {
// set filter according to data in store // set filter according to data in store
@ -111,9 +125,10 @@ class ChartContainer extends React.PureComponent {
}, },
data: { data: {
csv_endpoint: props.queryResponse.csv_endpoint, csv_endpoint: getExploreUrl(this.props.formData, this.props.datasource_type, 'csv'),
json_endpoint: props.queryResponse.json_endpoint, json_endpoint: getExploreUrl(this.props.formData, this.props.datasource_type, 'json'),
standalone_endpoint: props.queryResponse.standalone_endpoint, standalone_endpoint: getExploreUrl(
this.props.formData, this.props.datasource_type, 'standalone'),
}, },
}; };
@ -125,26 +140,45 @@ class ChartContainer extends React.PureComponent {
renderChartTitle() { renderChartTitle() {
let title; let title;
if (this.props.slice_name) { if (this.props.slice) {
title = this.props.slice_name; title = this.props.slice.slice_name;
} else { } else {
title = `[${this.props.table_name}] - untitled`; title = `[${this.props.table_name}] - untitled`;
} }
return title; return title;
} }
renderAlert() {
const msg = (
<div>
{this.props.alert}
<i
className="fa fa-close pull-right"
onClick={this.removeAlert.bind(this)}
style={{ cursor: 'pointer' }}
/>
</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() { renderChart() {
if (this.props.alert) { if (this.props.alert) {
return ( return this.renderAlert();
<Alert bsStyle="warning">
{this.props.alert}
<i
className="fa fa-close pull-right"
onClick={this.removeAlert.bind(this)}
style={{ cursor: 'pointer' }}
/>
</Alert>
);
} }
const loading = this.props.chartStatus === 'loading'; const loading = this.props.chartStatus === 'loading';
return ( return (
@ -170,6 +204,9 @@ class ChartContainer extends React.PureComponent {
} }
render() { render() {
if (this.props.standalone) {
return this.renderChart();
}
return ( return (
<div className="chart-container"> <div className="chart-container">
<Panel <Panel
@ -181,10 +218,10 @@ class ChartContainer extends React.PureComponent {
> >
{this.renderChartTitle()} {this.renderChartTitle()}
{this.props.slice_id && {this.props.slice &&
<span> <span>
<FaveStar <FaveStar
sliceId={this.props.slice_id} sliceId={this.props.slice.slice_id}
actions={this.props.actions} actions={this.props.actions}
isStarred={this.props.isStarred} isStarred={this.props.isStarred}
/> />
@ -195,7 +232,7 @@ class ChartContainer extends React.PureComponent {
> >
<a <a
className="edit-desc-icon" className="edit-desc-icon"
href={`/slicemodelview/edit/${this.props.slice_id}`} href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
> >
<i className="fa fa-edit" /> <i className="fa fa-edit" />
</a> </a>
@ -208,16 +245,15 @@ class ChartContainer extends React.PureComponent {
startTime={this.props.chartUpdateStartTime} startTime={this.props.chartUpdateStartTime}
endTime={this.props.chartUpdateEndTime} endTime={this.props.chartUpdateEndTime}
isRunning={this.props.chartStatus === 'loading'} isRunning={this.props.chartStatus === 'loading'}
state={CHART_STATUS_MAP[this.props.chartStatus]} status={CHART_STATUS_MAP[this.props.chartStatus]}
style={{ fontSize: '10px', marginRight: '5px' }} style={{ fontSize: '10px', marginRight: '5px' }}
/> />
{this.state.mockSlice && <ExploreActionButtons
<ExploreActionButtons slice={this.state.mockSlice}
slice={this.state.mockSlice} canDownload={this.props.can_download}
canDownload={this.props.can_download} queryEndpoint={getExploreUrl(
query={this.props.queryResponse.query} this.props.latestQueryFormData, this.props.datasource_type, 'query')}
/> />
}
</div> </div>
</div> </div>
} }
@ -232,21 +268,24 @@ class ChartContainer extends React.PureComponent {
ChartContainer.propTypes = propTypes; ChartContainer.propTypes = propTypes;
function mapStateToProps(state) { function mapStateToProps(state) {
const formData = getFormDataFromFields(state.fields);
return { return {
containerId: `slice-container-${state.viz.form_data.slice_id}`,
slice_id: state.viz.form_data.slice_id,
slice_name: state.viz.form_data.slice_name,
viz_type: state.viz.form_data.viz_type,
can_download: state.can_download,
chartUpdateStartTime: state.chartUpdateStartTime,
chartUpdateEndTime: state.chartUpdateEndTime,
query: state.viz.query,
column_formats: state.viz.column_formats,
chartStatus: state.chartStatus,
isStarred: state.isStarred,
alert: state.chartAlert, alert: state.chartAlert,
table_name: state.viz.form_data.datasource_name, 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, queryResponse: state.queryResponse,
slice: state.slice,
standalone: state.standalone,
table_name: formData.datasource_name,
viz_type: formData.viz_type,
triggerRender: state.triggerRender,
}; };
} }

View File

@ -6,41 +6,72 @@ const propTypes = {
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
description: PropTypes.string, description: PropTypes.string,
validationErrors: PropTypes.array, validationErrors: PropTypes.array,
renderTrigger: PropTypes.bool,
rightNode: PropTypes.node,
}; };
const defaultProps = { const defaultProps = {
description: null,
validationErrors: [], validationErrors: [],
renderTrigger: false,
}; };
export default function ControlHeader({ label, description, validationErrors }) { export default function ControlHeader({
label, description, validationErrors, renderTrigger, rightNode }) {
const hasError = (validationErrors.length > 0); const hasError = (validationErrors.length > 0);
return ( return (
<ControlLabel> <div>
{hasError ? <div className="pull-left">
<strong className="text-danger">{label}</strong> : <ControlLabel>
<span>{label}</span> {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> {(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>
} }
{description && <div className="clearfix" />
<InfoTooltipWithTrigger label={label} tooltip={description} /> </div>
}
</ControlLabel>
); );
} }

View File

@ -4,10 +4,11 @@ import { bindActionCreators } from 'redux';
import * as actions from '../actions/exploreActions'; import * as actions from '../actions/exploreActions';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Panel, Alert } from 'react-bootstrap'; import { Panel, Alert } from 'react-bootstrap';
import visTypes, { sectionsToRender } from '../stores/visTypes'; import { sectionsToRender } from '../stores/visTypes';
import ControlPanelSection from './ControlPanelSection'; import ControlPanelSection from './ControlPanelSection';
import FieldSetRow from './FieldSetRow'; import FieldSetRow from './FieldSetRow';
import FieldSet from './FieldSet'; import FieldSet from './FieldSet';
import fields from '../stores/fields';
const propTypes = { const propTypes = {
datasource_type: PropTypes.string.isRequired, datasource_type: PropTypes.string.isRequired,
@ -23,44 +24,19 @@ const propTypes = {
class ControlPanelsContainer extends React.Component { class ControlPanelsContainer extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.fieldOverrides = this.fieldOverrides.bind(this);
this.getFieldData = this.getFieldData.bind(this);
this.removeAlert = this.removeAlert.bind(this); this.removeAlert = this.removeAlert.bind(this);
this.getFieldData = this.getFieldData.bind(this);
} }
componentWillMount() { getFieldData(fieldName) {
const datasource_id = this.props.form_data.datasource; const mapF = fields[fieldName].mapStateToProps;
const datasource_type = this.props.datasource_type; if (mapF) {
if (datasource_id) { return Object.assign({}, this.props.fields[fieldName], mapF(this.props.exploreState));
this.props.actions.fetchDatasourceMetadata(datasource_id, datasource_type);
} }
} return this.props.fields[fieldName];
componentWillReceiveProps(nextProps) {
if (nextProps.form_data.datasource !== this.props.form_data.datasource) {
if (nextProps.form_data.datasource) {
this.props.actions.fetchDatasourceMetadata(
nextProps.form_data.datasource, nextProps.datasource_type);
}
}
}
getFieldData(fs) {
const fieldOverrides = this.fieldOverrides();
let fieldData = this.props.fields[fs] || {};
if (fieldOverrides.hasOwnProperty(fs)) {
const overrideData = fieldOverrides[fs];
fieldData = Object.assign({}, fieldData, overrideData);
}
if (fieldData.mapStateToProps) {
Object.assign(fieldData, fieldData.mapStateToProps(this.props.exploreState));
}
return fieldData;
} }
sectionsToRender() { sectionsToRender() {
return sectionsToRender(this.props.form_data.viz_type, this.props.datasource_type); return sectionsToRender(this.props.form_data.viz_type, this.props.datasource_type);
} }
fieldOverrides() {
const viz = visTypes[this.props.form_data.viz_type];
return viz.fieldOverrides || {};
}
removeAlert() { removeAlert() {
this.props.actions.removeControlPanelAlert(); this.props.actions.removeControlPanelAlert();
} }
@ -78,7 +54,7 @@ class ControlPanelsContainer extends React.Component {
/> />
</Alert> </Alert>
} }
{!this.props.isDatasourceMetaLoading && this.sectionsToRender().map((section) => ( {this.sectionsToRender().map((section) => (
<ControlPanelSection <ControlPanelSection
key={section.label} key={section.label}
label={section.label} label={section.label}
@ -94,7 +70,6 @@ class ControlPanelsContainer extends React.Component {
value={this.props.form_data[fieldName]} value={this.props.form_data[fieldName]}
validationErrors={this.props.fields[fieldName].validationErrors} validationErrors={this.props.fields[fieldName].validationErrors}
actions={this.props.actions} actions={this.props.actions}
prefix={section.prefix}
{...this.getFieldData(fieldName)} {...this.getFieldData(fieldName)}
/> />
))} ))}

View File

@ -0,0 +1,59 @@
import React, { PropTypes } from 'react';
import ModalTrigger from './../../components/ModalTrigger';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { github } from 'react-syntax-highlighter/dist/styles';
const $ = window.$ = require('jquery');
const propTypes = {
queryEndpoint: PropTypes.string.isRequired,
};
export default class DisplayQueryButton extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
modalBody: <pre />,
};
}
beforeOpen() {
this.setState({
modalBody:
(<img
className="loading"
alt="Loading..."
src="/static/assets/images/loading.gif"
/>),
});
$.ajax({
type: 'GET',
url: this.props.queryEndpoint,
success: (data) => {
const modalBody = data.language ?
<SyntaxHighlighter language={data.language} style={github}>
{data.query}
</SyntaxHighlighter>
:
<pre>{data.query}</pre>;
this.setState({ modalBody });
},
error(data) {
this.setState({ modalBody: (<pre>{data.error}</pre>) });
},
});
}
render() {
return (
<ModalTrigger
isButton
triggerNode={<span>Query</span>}
modalTitle="Query"
bsSize="large"
beforeOpen={this.beforeOpen.bind(this)}
modalBody={this.state.modalBody}
/>
);
}
}
DisplayQueryButton.propTypes = propTypes;

View File

@ -0,0 +1,53 @@
import React, { PropTypes } from 'react';
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,
};
export default function ExploreActionButtons({ canDownload, slice, 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"
>
<i className="fa fa-file-code-o"></i> .json
</a>
<a
href={slice.data.csv_endpoint}
className={exportToCSVClasses}
title="Export to .csv format"
target="_blank"
>
<i className="fa fa-file-text-o"></i> .csv
</a>
<DisplayQueryButton
queryEndpoint={queryEndpoint}
/>
</div>
);
}
return (
<DisplayQueryButton queryEndpoint={queryEndpoint} />
);
}
ExploreActionButtons.propTypes = propTypes;

View File

@ -1,24 +1,27 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import React from 'react'; import React, { PropTypes } from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import * as actions from '../actions/exploreActions'; import * as actions from '../actions/exploreActions';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ChartContainer from './ChartContainer'; import ChartContainer from './ChartContainer';
import ControlPanelsContainer from './ControlPanelsContainer'; import ControlPanelsContainer from './ControlPanelsContainer';
import SaveModal from './SaveModal'; import SaveModal from './SaveModal';
import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns'; import QueryAndSaveBtns from './QueryAndSaveBtns';
import { autoQueryFields } from '../stores/fields';
import { getExploreUrl } from '../exploreUtils'; import { getExploreUrl } from '../exploreUtils';
import { getFormDataFromFields } from '../stores/store';
const propTypes = { const propTypes = {
form_data: React.PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
actions: React.PropTypes.object.isRequired, datasource_type: PropTypes.string.isRequired,
datasource_type: React.PropTypes.string.isRequired, chartStatus: PropTypes.string.isRequired,
chartStatus: React.PropTypes.string.isRequired, fields: PropTypes.object.isRequired,
fields: React.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 { class ExploreViewContainer extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -29,17 +32,23 @@ class ExploreViewContainer extends React.Component {
} }
componentDidMount() { componentDidMount() {
this.props.actions.fetchDatasources();
window.addEventListener('resize', this.handleResize.bind(this)); window.addEventListener('resize', this.handleResize.bind(this));
this.runQuery();
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(np) {
const refreshChart = Object.keys(nextProps.form_data).some((field) => ( if (np.fields.viz_type.value !== this.props.fields.viz_type.value) {
nextProps.form_data[field] !== this.props.form_data[field] this.props.actions.resetFields();
&& autoQueryFields.indexOf(field) !== -1) this.props.actions.triggerQuery();
); }
if (refreshChart) { if (np.fields.datasource.value !== this.props.fields.datasource.value) {
this.onQuery(); this.props.actions.fetchDatasourceMetadata(np.form_data.datasource, true);
}
}
componentDidUpdate() {
if (this.props.triggerQuery) {
this.runQuery();
} }
} }
@ -48,19 +57,26 @@ class ExploreViewContainer extends React.Component {
} }
onQuery() { onQuery() {
// remove alerts when query
this.props.actions.removeControlPanelAlert();
this.props.actions.removeChartAlert();
this.runQuery(); this.runQuery();
history.pushState( history.pushState(
{}, {},
document.title, document.title,
getExploreUrl(this.props.form_data, this.props.datasource_type) getExploreUrl(this.props.form_data));
); }
// remove alerts when query
this.props.actions.removeControlPanelAlert(); onStop() {
this.props.actions.removeChartAlert(); this.props.actions.chartUpdateStopped(this.props.queryRequest);
} }
getHeight() { getHeight() {
const navHeight = 90; if (this.props.forcedHeight) {
return this.props.forcedHeight + 'px';
}
const navHeight = this.props.standalone ? 0 : 90;
return `${window.innerHeight - navHeight}px`; return `${window.innerHeight - navHeight}px`;
} }
@ -101,8 +117,18 @@ class ExploreViewContainer extends React.Component {
} }
return errorMessage; return errorMessage;
} }
renderChartContainer() {
return (
<ChartContainer
actions={this.props.actions}
height={this.state.height}
/>);
}
render() { render() {
if (this.props.standalone) {
return this.renderChartContainer();
}
return ( return (
<div <div
id="explore-container" id="explore-container"
@ -117,7 +143,6 @@ class ExploreViewContainer extends React.Component {
onHide={this.toggleModal.bind(this)} onHide={this.toggleModal.bind(this)}
actions={this.props.actions} actions={this.props.actions}
form_data={this.props.form_data} form_data={this.props.form_data}
datasource_type={this.props.datasource_type}
/> />
} }
<div className="row"> <div className="row">
@ -126,7 +151,8 @@ class ExploreViewContainer extends React.Component {
canAdd="True" canAdd="True"
onQuery={this.onQuery.bind(this)} onQuery={this.onQuery.bind(this)}
onSave={this.toggleModal.bind(this)} onSave={this.toggleModal.bind(this)}
disabled={this.props.chartStatus === 'loading'} onStop={this.onStop.bind(this)}
loading={this.props.chartStatus === 'loading'}
errorMessage={this.renderErrorMessage()} errorMessage={this.renderErrorMessage()}
/> />
<br /> <br />
@ -134,14 +160,10 @@ class ExploreViewContainer extends React.Component {
actions={this.props.actions} actions={this.props.actions}
form_data={this.props.form_data} form_data={this.props.form_data}
datasource_type={this.props.datasource_type} datasource_type={this.props.datasource_type}
onQuery={this.onQuery.bind(this)}
/> />
</div> </div>
<div className="col-sm-8"> <div className="col-sm-8">
<ChartContainer {this.renderChartContainer()}
actions={this.props.actions}
height={this.state.height}
/>
</div> </div>
</div> </div>
</div> </div>
@ -152,11 +174,16 @@ class ExploreViewContainer extends React.Component {
ExploreViewContainer.propTypes = propTypes; ExploreViewContainer.propTypes = propTypes;
function mapStateToProps(state) { function mapStateToProps(state) {
const form_data = getFormDataFromFields(state.fields);
return { return {
chartStatus: state.chartStatus, chartStatus: state.chartStatus,
datasource_type: state.datasource_type, datasource_type: state.datasource_type,
fields: state.fields, fields: state.fields,
form_data: state.viz.form_data, form_data,
standalone: state.standalone,
triggerQuery: state.triggerQuery,
forcedHeight: state.forced_height,
queryRequest: state.queryRequest,
}; };
} }

View File

@ -1,17 +1,19 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import TextField from './TextField';
import CheckboxField from './CheckboxField'; import CheckboxField from './CheckboxField';
import TextAreaField from './TextAreaField';
import SelectField from './SelectField';
import FilterField from './FilterField';
import ControlHeader from './ControlHeader'; import ControlHeader from './ControlHeader';
import FilterField from './FilterField';
import HiddenField from './HiddenField';
import SelectField from './SelectField';
import TextAreaField from './TextAreaField';
import TextField from './TextField';
const fieldMap = { const fieldMap = {
TextField,
CheckboxField, CheckboxField,
TextAreaField,
SelectField,
FilterField, FilterField,
HiddenField,
SelectField,
TextAreaField,
TextField,
}; };
const fieldTypes = Object.keys(fieldMap); const fieldTypes = Object.keys(fieldMap);
@ -25,6 +27,8 @@ const propTypes = {
places: PropTypes.number, places: PropTypes.number,
validators: PropTypes.array, validators: PropTypes.array,
validationErrors: PropTypes.array, validationErrors: PropTypes.array,
renderTrigger: PropTypes.bool,
rightNode: PropTypes.node,
value: PropTypes.oneOfType([ value: PropTypes.oneOfType([
PropTypes.string, PropTypes.string,
PropTypes.number, PropTypes.number,
@ -33,6 +37,7 @@ const propTypes = {
}; };
const defaultProps = { const defaultProps = {
renderTrigger: false,
validators: [], validators: [],
validationErrors: [], validationErrors: [],
}; };
@ -65,12 +70,15 @@ export default class FieldSet extends React.PureComponent {
} }
render() { render() {
const FieldType = fieldMap[this.props.type]; const FieldType = fieldMap[this.props.type];
const divStyle = this.props.hidden ? { display: 'none' } : null;
return ( return (
<div> <div style={divStyle}>
<ControlHeader <ControlHeader
label={this.props.label} label={this.props.label}
description={this.props.description} description={this.props.description}
renderTrigger={this.props.renderTrigger}
validationErrors={this.props.validationErrors} validationErrors={this.props.validationErrors}
rightNode={this.props.rightNode}
/> />
<FieldType <FieldType
onChange={this.onChange} onChange={this.onChange}

View File

@ -6,14 +6,15 @@ import SelectField from './SelectField';
const propTypes = { const propTypes = {
choices: PropTypes.array, choices: PropTypes.array,
opChoices: PropTypes.array,
changeFilter: PropTypes.func, changeFilter: PropTypes.func,
removeFilter: PropTypes.func, removeFilter: PropTypes.func,
filter: PropTypes.object.isRequired, filter: PropTypes.object.isRequired,
datasource: PropTypes.object, datasource: PropTypes.object,
having: PropTypes.bool,
}; };
const defaultProps = { const defaultProps = {
having: false,
changeFilter: () => {}, changeFilter: () => {},
removeFilter: () => {}, removeFilter: () => {},
choices: [], choices: [],
@ -21,6 +22,11 @@ const defaultProps = {
}; };
export default class Filter extends React.Component { export default class Filter extends React.Component {
constructor(props) {
super(props);
this.opChoices = this.props.having ? ['==', '!=', '>', '<', '>=', '<=']
: ['in', 'not in'];
}
fetchFilterValues(col) { fetchFilterValues(col) {
if (!this.props.datasource) { if (!this.props.datasource) {
return; return;
@ -61,24 +67,27 @@ export default class Filter extends React.Component {
if (!filter.choices) { if (!filter.choices) {
this.fetchFilterValues(filter.col); this.fetchFilterValues(filter.col);
} }
}
if (this.props.having) {
// druid having filter
return ( return (
<SelectField <input
multi type="text"
freeForm onChange={this.changeFilter.bind(this, 'val')}
name="filter-value"
value={filter.value} value={filter.value}
choices={filter.choices} className="form-control input-sm"
onChange={this.changeFilter.bind(this, 'value')} placeholder="Filter value"
/> />
); );
} }
return ( return (
<input <SelectField
type="text" multi
onChange={this.changeFilter.bind(this, 'value')} freeForm
value={filter.value} name="filter-value"
className="form-control input-sm" value={filter.val}
placeholder="Filter value" choices={filter.choices || []}
onChange={this.changeFilter.bind(this, 'val')}
/> />
); );
} }
@ -102,7 +111,7 @@ export default class Filter extends React.Component {
<Select <Select
id="select-op" id="select-op"
placeholder="Select operator" placeholder="Select operator"
options={this.props.opChoices.map((o) => ({ value: o, label: o }))} options={this.opChoices.map((o) => ({ value: o, label: o }))}
value={filter.op} value={filter.op}
onChange={this.changeFilter.bind(this, 'op')} onChange={this.changeFilter.bind(this, 'op')}
/> />

View File

@ -3,7 +3,7 @@ import { Button, Row, Col } from 'react-bootstrap';
import Filter from './Filter'; import Filter from './Filter';
const propTypes = { const propTypes = {
prefix: PropTypes.string, name: PropTypes.string,
choices: PropTypes.array, choices: PropTypes.array,
onChange: PropTypes.func, onChange: PropTypes.func,
value: PropTypes.array, value: PropTypes.array,
@ -11,25 +11,18 @@ const propTypes = {
}; };
const defaultProps = { const defaultProps = {
prefix: 'flt',
choices: [], choices: [],
onChange: () => {}, onChange: () => {},
value: [], value: [],
}; };
export default class FilterField extends React.Component { export default class FilterField extends React.Component {
constructor(props) {
super(props);
this.opChoices = props.prefix === 'flt' ?
['in', 'not in'] : ['==', '!=', '>', '<', '>=', '<='];
}
addFilter() { addFilter() {
const newFilters = Object.assign([], this.props.value); const newFilters = Object.assign([], this.props.value);
newFilters.push({ newFilters.push({
prefix: this.props.prefix,
col: null, col: null,
op: 'in', op: 'in',
value: this.props.datasource.filter_select ? [] : '', val: this.props.datasource.filter_select ? [] : '',
}); });
this.props.onChange(newFilters); this.props.onChange(newFilters);
} }
@ -46,22 +39,19 @@ export default class FilterField extends React.Component {
render() { render() {
const filters = []; const filters = [];
this.props.value.forEach((filter, i) => { this.props.value.forEach((filter, i) => {
// only display filters with current prefix const filterBox = (
if (filter.prefix === this.props.prefix) { <div key={i}>
const filterBox = ( <Filter
<div key={i}> having={this.props.name === 'having_filters'}
<Filter filter={filter}
filter={filter} choices={this.props.choices}
choices={this.props.choices} datasource={this.props.datasource}
opChoices={this.opChoices} removeFilter={this.removeFilter.bind(this, i)}
datasource={this.props.datasource} changeFilter={this.changeFilter.bind(this, i)}
removeFilter={this.removeFilter.bind(this, i)} />
changeFilter={this.changeFilter.bind(this, i)} </div>
/> );
</div> filters.push(filterBox);
);
filters.push(filterBox);
}
}); });
return ( return (
<div> <div>

View File

@ -0,0 +1,24 @@
import React, { PropTypes } from 'react';
import { FormControl } from 'react-bootstrap';
const propTypes = {
onChange: PropTypes.func,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
};
const defaultProps = {
onChange: () => {},
};
export default class HiddenField extends React.PureComponent {
render() {
// This wouldn't be necessary but might as well
return <FormControl type="hidden" value={this.props.value} />;
}
}
HiddenField.propTypes = propTypes;
HiddenField.defaultProps = defaultProps;

View File

@ -7,38 +7,50 @@ const propTypes = {
canAdd: PropTypes.string.isRequired, canAdd: PropTypes.string.isRequired,
onQuery: PropTypes.func.isRequired, onQuery: PropTypes.func.isRequired,
onSave: PropTypes.func, onSave: PropTypes.func,
disabled: PropTypes.bool, onStop: PropTypes.func,
loading: PropTypes.bool,
errorMessage: PropTypes.string, errorMessage: PropTypes.string,
}; };
const defaultProps = { const defaultProps = {
onStop: () => {},
onSave: () => {}, onSave: () => {},
disabled: false, disabled: false,
}; };
export default function QueryAndSaveBtns({ canAdd, onQuery, onSave, disabled, errorMessage }) { export default function QueryAndSaveBtns(
{ canAdd, onQuery, onSave, onStop, loading, errorMessage }) {
const saveClasses = classnames({ const saveClasses = classnames({
'disabled disabledButton': canAdd !== 'True', 'disabled disabledButton': canAdd !== 'True',
}); });
const qryButtonStyle = errorMessage ? 'danger' : 'primary'; const qryButtonStyle = errorMessage ? 'danger' : 'primary';
const qryButtonDisabled = errorMessage ? true : disabled; 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}
>
<i className="fa fa-bolt" /> Query
</Button>
);
return ( return (
<div> <div>
<ButtonGroup className="query-and-save"> <ButtonGroup className="query-and-save">
<Button {qryOrStopButton}
id="query_button"
onClick={onQuery}
disabled={qryButtonDisabled}
bsStyle={qryButtonStyle}
>
<i className="fa fa-bolt" /> Query
</Button>
<Button <Button
className={saveClasses} className={saveClasses}
data-target="#save_modal" data-target="#save_modal"
data-toggle="modal" data-toggle="modal"
disabled={qryButtonDisabled} disabled={saveButtonDisabled}
onClick={onSave} onClick={onSave}
> >
<i className="fa fa-plus-circle"></i> Save as <i className="fa fa-plus-circle"></i> Save as

View File

@ -1,20 +1,20 @@
/* eslint camel-case: 0 */ /* eslint camelcase: 0 */
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import $ from 'jquery'; import $ from 'jquery';
import { Modal, Alert, Button, Radio } from 'react-bootstrap'; import { Modal, Alert, Button, Radio } from 'react-bootstrap';
import Select from 'react-select'; import Select from 'react-select';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getParamObject } from '../exploreUtils';
const propTypes = { const propTypes = {
can_edit: PropTypes.bool, can_overwrite: PropTypes.bool,
onHide: PropTypes.func.isRequired, onHide: PropTypes.func.isRequired,
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
form_data: PropTypes.object, form_data: PropTypes.object,
datasource_type: PropTypes.string.isRequired,
user_id: PropTypes.string.isRequired, user_id: PropTypes.string.isRequired,
dashboards: PropTypes.array.isRequired, dashboards: PropTypes.array.isRequired,
alert: PropTypes.string, alert: PropTypes.string,
slice: PropTypes.object,
datasource: PropTypes.object,
}; };
class SaveModal extends React.Component { class SaveModal extends React.Component {
@ -26,7 +26,7 @@ class SaveModal extends React.Component {
newSliceName: '', newSliceName: '',
dashboards: [], dashboards: [],
alert: null, alert: null,
action: 'overwrite', action: 'saveas',
addToDash: 'noSave', addToDash: 'noSave',
}; };
} }
@ -58,13 +58,13 @@ class SaveModal extends React.Component {
saveOrOverwrite(gotodash) { saveOrOverwrite(gotodash) {
this.setState({ alert: null }); this.setState({ alert: null });
this.props.actions.removeSaveModalAlert(); this.props.actions.removeSaveModalAlert();
const params = getParamObject(
this.props.form_data, this.props.datasource_type, this.state.action === 'saveas');
const sliceParams = {}; const sliceParams = {};
params.datasource_name = this.props.form_data.datasource_name;
let sliceName = null; let sliceName = null;
sliceParams.action = this.state.action; sliceParams.action = this.state.action;
if (this.props.slice.slice_id) {
sliceParams.slice_id = this.props.slice.slice_id;
}
if (sliceParams.action === 'saveas') { if (sliceParams.action === 'saveas') {
sliceName = this.state.newSliceName; sliceName = this.state.newSliceName;
if (sliceName === '') { if (sliceName === '') {
@ -73,7 +73,7 @@ class SaveModal extends React.Component {
} }
sliceParams.slice_name = sliceName; sliceParams.slice_name = sliceName;
} else { } else {
sliceParams.slice_name = this.props.form_data.slice_name; sliceParams.slice_name = this.props.slice.slice_name;
} }
const addToDash = this.state.addToDash; const addToDash = this.state.addToDash;
@ -100,9 +100,13 @@ class SaveModal extends React.Component {
dashboard = null; dashboard = null;
} }
sliceParams.goto_dash = gotodash; sliceParams.goto_dash = gotodash;
const baseUrl = '/superset/explore/' +
`${this.props.datasource_type}/${this.props.form_data.datasource}/`; const baseUrl = `/superset/explore/${this.props.datasource.type}/${this.props.datasource.id}/`;
const saveUrl = `${baseUrl}?${$.param(params, true)}&${$.param(sliceParams, true)}`; 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.actions.saveSlice(saveUrl);
this.props.onHide(); this.props.onHide();
} }
@ -136,11 +140,11 @@ class SaveModal extends React.Component {
</Alert> </Alert>
} }
<Radio <Radio
disabled={!this.props.can_edit} disabled={!this.props.can_overwrite}
checked={this.state.action === 'overwrite'} checked={this.state.action === 'overwrite'}
onChange={this.changeAction.bind(this, 'overwrite')} onChange={this.changeAction.bind(this, 'overwrite')}
> >
{`Overwrite slice ${this.props.form_data.slice_name}`} {`Overwrite slice ${this.props.slice.slice_name}`}
</Radio> </Radio>
<Radio <Radio
@ -223,7 +227,9 @@ SaveModal.propTypes = propTypes;
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
can_edit: state.can_edit, datasource: state.datasource,
slice: state.slice,
can_overwrite: state.can_overwrite,
user_id: state.user_id, user_id: state.user_id,
dashboards: state.dashboards, dashboards: state.dashboards,
alert: state.saveModalAlert, alert: state.saveModalAlert,

View File

@ -5,8 +5,8 @@ const propTypes = {
choices: PropTypes.array, choices: PropTypes.array,
clearable: PropTypes.bool, clearable: PropTypes.bool,
description: PropTypes.string, description: PropTypes.string,
editUrl: PropTypes.string,
freeForm: PropTypes.bool, freeForm: PropTypes.bool,
isLoading: PropTypes.bool,
label: PropTypes.string, label: PropTypes.string,
multi: PropTypes.bool, multi: PropTypes.bool,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
@ -18,21 +18,26 @@ const defaultProps = {
choices: [], choices: [],
clearable: true, clearable: true,
description: null, description: null,
editUrl: null,
freeForm: false, freeForm: false,
isLoading: false,
label: null, label: null,
multi: false, multi: false,
onChange: () => {}, onChange: () => {},
value: '',
}; };
export default class SelectField extends React.Component { export default class SelectField extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { options: this.getOptions() }; this.state = { options: this.getOptions(props) };
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
this.renderOption = this.renderOption.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) { onChange(opt) {
let optionValue = opt ? opt.value : null; let optionValue = opt ? opt.value : null;
// if multi, return options values as an array // if multi, return options values as an array
@ -41,8 +46,8 @@ export default class SelectField extends React.Component {
} }
this.props.onChange(optionValue); this.props.onChange(optionValue);
} }
getOptions() { getOptions(props) {
const options = this.props.choices.map((c) => { const options = props.choices.map((c) => {
const label = c.length > 1 ? c[1] : c[0]; const label = c.length > 1 ? c[1] : c[0];
const newOptions = { const newOptions = {
value: c[0], value: c[0],
@ -51,19 +56,19 @@ export default class SelectField extends React.Component {
if (c[2]) newOptions.imgSrc = c[2]; if (c[2]) newOptions.imgSrc = c[2];
return newOptions; return newOptions;
}); });
if (this.props.freeForm) { if (props.freeForm) {
// For FreeFormSelect, insert value into options if not exist // For FreeFormSelect, insert value into options if not exist
const values = this.props.choices.map((c) => c[0]); const values = props.choices.map((c) => c[0]);
if (this.props.value) { if (props.value) {
if (typeof this.props.value === 'object') { if (typeof props.value === 'object') {
this.props.value.forEach((v) => { props.value.forEach((v) => {
if (values.indexOf(v) === -1) { if (values.indexOf(v) === -1) {
options.push({ value: v, label: v }); options.push({ value: v, label: v });
} }
}); });
} else { } else {
if (values.indexOf(this.props.value) === -1) { if (values.indexOf(props.value) === -1) {
options.push({ value: this.props.value, label: this.props.value }); options.push({ value: props.value, label: props.value });
} }
} }
} }
@ -91,6 +96,7 @@ export default class SelectField extends React.Component {
value: this.props.value, value: this.props.value,
autosize: false, autosize: false,
clearable: this.props.clearable, clearable: this.props.clearable,
isLoading: this.props.isLoading,
onChange: this.onChange, onChange: this.onChange,
optionRenderer: this.renderOption, optionRenderer: this.renderOption,
}; };
@ -100,9 +106,6 @@ export default class SelectField extends React.Component {
return ( return (
<div> <div>
{selectWrap} {selectWrap}
{this.props.editUrl &&
<a href={`${this.props.editUrl}/${this.props.value}`}>edit</a>
}
</div> </div>
); );
} }

View File

@ -1,55 +1,19 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
const $ = require('jquery'); export function getExploreUrl(form_data, dummy, endpoint = 'base') {
function formatFilters(filters) { const [datasource_id, datasource_type] = form_data.datasource.split('__');
// outputs an object of url params of filters let params = `${datasource_type}/${datasource_id}/`;
// prefix can be 'flt' or 'having' params += '?form_data=' + encodeURIComponent(JSON.stringify(form_data));
const params = {};
for (let i = 0; i < filters.length; i++) {
const filter = filters[i];
params[`${filter.prefix}_col_${i + 1}`] = filter.col;
params[`${filter.prefix}_op_${i + 1}`] = filter.op;
if (filter.value.constructor === Array) {
params[`${filter.prefix}_eq_${i + 1}`] = filter.value.join(',');
} else {
params[`${filter.prefix}_eq_${i + 1}`] = filter.value;
}
}
return params;
}
export function getParamObject(form_data, datasource_type, saveNewSlice) {
const data = {
// V2 tag temporarily for updating url
// Todo: remove after launch
V2: true,
datasource_id: form_data.datasource,
datasource_type,
};
Object.keys(form_data).forEach((field) => {
// filter out null fields
if (form_data[field] !== null && field !== 'datasource' && field !== 'filters'
&& !(saveNewSlice && field === 'slice_name')) {
data[field] = form_data[field];
}
});
const filterParams = formatFilters(form_data.filters);
Object.assign(data, filterParams);
return data;
}
export function getExploreUrl(form_data, datasource_type, endpoint = 'base') {
const data = getParamObject(form_data, datasource_type);
const params = `${datasource_type}/` +
`${form_data.datasource}/?${$.param(data, true)}`;
switch (endpoint) { switch (endpoint) {
case 'base': case 'base':
return `/superset/explore/${params}`; return `/superset/explore/${params}`;
case 'json': case 'json':
return `/superset/explore_json/${params}`; return `/superset/explore_json/${params}`;
case 'csv': case 'csv':
return `/superset/explore/${params}&csv=true`; return `/superset/explore_json/${params}&csv=true`;
case 'standalone': case 'standalone':
return `/superset/explore/${params}&standalone=true`; return `/superset/explore/${params}&standalone=true`;
case 'query':
return `/superset/explore_json/${params}&query=true`;
default: default:
return `/superset/explore/${params}`; return `/superset/explore/${params}`;
} }

View File

@ -7,6 +7,8 @@ import { Provider } from 'react-redux';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { now } from '../modules/dates'; import { now } from '../modules/dates';
import { initEnhancer } from '../reduxUtils'; import { initEnhancer } from '../reduxUtils';
import { getFieldsState, getFormDataFromFields } from './stores/store';
// jquery and bootstrap required to make bootstrap dropdown menu's work // jquery and bootstrap required to make bootstrap dropdown menu's work
const $ = window.$ = require('jquery'); // eslint-disable-line const $ = window.$ = require('jquery'); // eslint-disable-line
@ -14,58 +16,30 @@ const jQuery = window.jQuery = require('jquery'); // eslint-disable-line
require('bootstrap'); require('bootstrap');
require('./main.css'); require('./main.css');
import { initialState } from './stores/store';
const exploreViewContainer = document.getElementById('js-explore-view-container'); const exploreViewContainer = document.getElementById('js-explore-view-container');
const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap')); const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap'));
const fields = getFieldsState(bootstrapData, bootstrapData.form_data);
delete bootstrapData.form_data;
import { exploreReducer } from './reducers/exploreReducer'; import { exploreReducer } from './reducers/exploreReducer';
// Initial state
const bootstrappedState = Object.assign( const bootstrappedState = Object.assign(
initialState(bootstrapData.viz.form_data.viz_type, bootstrapData.datasource_type), { bootstrapData, {
can_edit: bootstrapData.can_edit,
can_download: bootstrapData.can_download,
datasources: bootstrapData.datasources,
datasource_type: bootstrapData.datasource_type,
viz: bootstrapData.viz,
user_id: bootstrapData.user_id,
chartUpdateStartTime: now(),
chartUpdateEndTime: null,
chartStatus: 'loading', chartStatus: 'loading',
chartUpdateEndTime: null,
chartUpdateStartTime: now(),
dashboards: [],
fields,
latestQueryFormData: getFormDataFromFields(fields),
filterColumnOpts: [],
isDatasourceMetaLoading: false,
isStarred: false,
queryResponse: null, queryResponse: null,
triggerQuery: true,
triggerRender: false,
} }
); );
bootstrappedState.viz.form_data.datasource = parseInt(bootstrapData.datasource_id, 10);
bootstrappedState.viz.form_data.datasource_name = bootstrapData.datasource_name;
function parseFilters(form_data, prefix = 'flt') {
const filters = [];
for (let i = 0; i <= 10; i++) {
if (form_data[`${prefix}_col_${i}`] && form_data[`${prefix}_op_${i}`]) {
filters.push({
prefix,
col: form_data[`${prefix}_col_${i}`],
op: form_data[`${prefix}_op_${i}`],
value: form_data[`${prefix}_eq_${i}`],
});
}
/* eslint no-param-reassign: 0 */
delete form_data[`${prefix}_col_${i}`];
delete form_data[`${prefix}_op_${i}`];
delete form_data[`${prefix}_eq_${i}`];
}
return filters;
}
function getFilters(form_data, datasource_type) {
if (datasource_type === 'table') {
return parseFilters(form_data);
}
return parseFilters(form_data).concat(parseFilters(form_data, 'having'));
}
bootstrappedState.viz.form_data.filters =
getFilters(bootstrappedState.viz.form_data, bootstrapData.datasource_type);
const store = createStore(exploreReducer, bootstrappedState, const store = createStore(exploreReducer, bootstrappedState,
compose(applyMiddleware(thunk), initEnhancer(false)) compose(applyMiddleware(thunk), initEnhancer(false))

View File

@ -1,5 +1,5 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import { defaultFormData } from '../stores/store'; import { getFieldsState, getFormDataFromFields } from '../stores/store';
import * as actions from '../actions/exploreActions'; import * as actions from '../actions/exploreActions';
import { now } from '../../modules/dates'; import { now } from '../../modules/dates';
@ -9,15 +9,15 @@ export const exploreReducer = function (state, action) {
return Object.assign({}, state, { isStarred: action.isStarred }); return Object.assign({}, state, { isStarred: action.isStarred });
}, },
[actions.FETCH_STARTED]() { [actions.FETCH_DATASOURCE_STARTED]() {
return Object.assign({}, state, { isDatasourceMetaLoading: true }); return Object.assign({}, state, { isDatasourceMetaLoading: true });
}, },
[actions.FETCH_SUCCEEDED]() { [actions.FETCH_DATASOURCE_SUCCEEDED]() {
return Object.assign({}, state, { isDatasourceMetaLoading: false }); return Object.assign({}, state, { isDatasourceMetaLoading: false });
}, },
[actions.FETCH_FAILED]() { [actions.FETCH_DATASOURCE_FAILED]() {
// todo(alanna) handle failure/error state // todo(alanna) handle failure/error state
return Object.assign({}, state, return Object.assign({}, state,
{ {
@ -25,6 +25,28 @@ export const exploreReducer = function (state, action) {
controlPanelAlert: action.error, 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]() { [actions.REMOVE_CONTROL_PANEL_ALERT]() {
return Object.assign({}, state, { controlPanelAlert: null }); return Object.assign({}, state, { controlPanelAlert: null });
}, },
@ -36,32 +58,17 @@ export const exploreReducer = function (state, action) {
return Object.assign({}, state, return Object.assign({}, state,
{ saveModalAlert: `fetching dashboards failed for ${action.userId}` }); { saveModalAlert: `fetching dashboards failed for ${action.userId}` });
}, },
[actions.SET_DATASOURCE]() {
return Object.assign({}, state, { datasource: action.datasource });
},
[actions.SET_FIELD_VALUE]() { [actions.SET_FIELD_VALUE]() {
let newFormData = Object.assign({}, state.viz.form_data);
if (action.fieldName === 'datasource') {
newFormData = defaultFormData(state.viz.form_data.viz_type, action.datasource_type);
newFormData.datasource_name = action.label;
newFormData.slice_id = state.viz.form_data.slice_id;
newFormData.slice_name = state.viz.form_data.slice_name;
newFormData.viz_type = state.viz.form_data.viz_type;
}
newFormData[action.fieldName] = action.value;
const fields = Object.assign({}, state.fields); const fields = Object.assign({}, state.fields);
const field = fields[action.fieldName]; const field = Object.assign({}, fields[action.fieldName]);
field.value = action.value; field.value = action.value;
field.validationErrors = action.validationErrors; field.validationErrors = action.validationErrors;
return Object.assign( fields[action.fieldName] = field;
{}, const changes = { fields };
state, if (field.renderTrigger) {
{ changes.triggerRender = true;
fields, }
viz: Object.assign({}, state.viz, { form_data: newFormData }), return Object.assign({}, state, changes);
}
);
}, },
[actions.CHART_UPDATE_SUCCEEDED]() { [actions.CHART_UPDATE_SUCCEEDED]() {
return Object.assign( return Object.assign(
@ -79,6 +86,16 @@ export const exploreReducer = function (state, action) {
chartStatus: 'loading', chartStatus: 'loading',
chartUpdateEndTime: null, chartUpdateEndTime: null,
chartUpdateStartTime: now(), chartUpdateStartTime: now(),
triggerQuery: false,
queryRequest: action.queryRequest,
latestQueryFormData: getFormDataFromFields(state.fields),
});
},
[actions.CHART_UPDATE_STOPPED]() {
return Object.assign({}, state,
{
chartStatus: 'stopped',
chartAlert: 'Updating chart was stopped',
}); });
}, },
[actions.CHART_RENDERING_FAILED]() { [actions.CHART_RENDERING_FAILED]() {
@ -87,10 +104,15 @@ export const exploreReducer = function (state, action) {
chartAlert: 'An error occurred while rendering the visualization: ' + action.error, chartAlert: 'An error occurred while rendering the visualization: ' + action.error,
}); });
}, },
[actions.TRIGGER_QUERY]() {
return Object.assign({}, state, {
triggerQuery: true,
});
},
[actions.CHART_UPDATE_FAILED]() { [actions.CHART_UPDATE_FAILED]() {
return Object.assign({}, state, { return Object.assign({}, state, {
chartStatus: 'failed', chartStatus: 'failed',
chartAlert: action.queryResponse.error, chartAlert: action.queryResponse ? action.queryResponse.error : 'Network error.',
chartUpdateEndTime: now(), chartUpdateEndTime: now(),
queryResponse: action.queryResponse, queryResponse: action.queryResponse,
}); });
@ -114,6 +136,13 @@ export const exploreReducer = function (state, action) {
[actions.REMOVE_SAVE_MODAL_ALERT]() { [actions.REMOVE_SAVE_MODAL_ALERT]() {
return Object.assign({}, state, { saveModalAlert: null }); return Object.assign({}, state, { saveModalAlert: null });
}, },
[actions.RESET_FIELDS]() {
const fields = getFieldsState(state, getFormDataFromFields(state.fields));
return Object.assign({}, state, { fields });
},
[actions.RENDER_TRIGGERED]() {
return Object.assign({}, state, { triggerRender: false });
},
}; };
if (action.type in actionHandlers) { if (action.type in actionHandlers) {
return actionHandlers[action.type](); return actionHandlers[action.type]();

View File

@ -1,4 +1,5 @@
import { formatSelectOptionsForRange, formatSelectOptions } from '../../modules/utils'; import { formatSelectOptionsForRange, formatSelectOptions } from '../../modules/utils';
import React from 'react';
import visTypes from './visTypes'; import visTypes from './visTypes';
import * as v from '../validators'; import * as v from '../validators';
@ -26,21 +27,23 @@ export const TIME_STAMP_OPTIONS = [
['%H:%M:%S', '%H:%M:%S | 01:32:10'], ['%H:%M:%S', '%H:%M:%S | 01:32:10'],
]; ];
const MAP_DATASOURCE_TYPE_TO_EDIT_URL = {
table: '/tablemodelview/edit',
druid: '/druiddatasourcemodelview/edit',
};
export const fields = { export const fields = {
datasource: { datasource: {
type: 'SelectField', type: 'SelectField',
label: 'Datasource', label: 'Datasource',
isLoading: true,
clearable: false, clearable: false,
default: null, default: null,
mapStateToProps: (state) => ({ mapStateToProps: (state) => {
choices: state.datasources || [], const datasources = state.datasources || [];
editUrl: MAP_DATASOURCE_TYPE_TO_EDIT_URL[state.datasource_type], return {
}), choices: datasources,
isLoading: datasources.length === 0,
rightNode: state.datasource ?
<a href={state.datasource.edit_url}>edit</a>
: null,
};
},
description: '', description: '',
}, },
@ -62,10 +65,10 @@ export const fields = {
multi: true, multi: true,
label: 'Metrics', label: 'Metrics',
validators: [v.nonEmpty], validators: [v.nonEmpty],
default: field => field.choices !== null ? [field.choices[0][0]] : null,
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.metrics_combo : [], choices: (state.datasource) ? state.datasource.metrics_combo : [],
}), }),
default: [],
description: 'One or many metrics to display', description: 'One or many metrics to display',
}, },
@ -83,10 +86,11 @@ export const fields = {
metric: { metric: {
type: 'SelectField', type: 'SelectField',
label: 'Metric', label: 'Metric',
default: null, clearable: false,
description: 'Choose the metric', description: 'Choose the metric',
default: field => field.choices && field.choices.length > 0 ? field.choices[0][0] : null,
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.metrics_combo : [], choices: (state.datasource) ? state.datasource.metrics_combo : null,
}), }),
}, },
@ -185,6 +189,7 @@ export const fields = {
bar_stacked: { bar_stacked: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Stacked Bars', label: 'Stacked Bars',
renderTrigger: true,
default: false, default: false,
description: null, description: null,
}, },
@ -192,6 +197,7 @@ export const fields = {
show_markers: { show_markers: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Show Markers', label: 'Show Markers',
renderTrigger: true,
default: false, default: false,
description: 'Show data points as circle markers on the lines', description: 'Show data points as circle markers on the lines',
}, },
@ -200,6 +206,7 @@ export const fields = {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Bar Values', label: 'Bar Values',
default: false, default: false,
renderTrigger: true,
description: 'Show the value on top of the bar', description: 'Show the value on top of the bar',
}, },
@ -213,6 +220,7 @@ export const fields = {
show_controls: { show_controls: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Extra Controls', label: 'Extra Controls',
renderTrigger: true,
default: false, default: false,
description: 'Whether to show extra controls or not. Extra controls ' + description: 'Whether to show extra controls or not. Extra controls ' +
'include things like making mulitBar charts stacked ' + 'include things like making mulitBar charts stacked ' +
@ -222,6 +230,7 @@ export const fields = {
reduce_x_ticks: { reduce_x_ticks: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Reduce X ticks', label: 'Reduce X ticks',
renderTrigger: true,
default: false, default: false,
description: 'Reduces the number of X axis ticks to be rendered. ' + description: 'Reduces the number of X axis ticks to be rendered. ' +
'If true, the x axis wont overflow and labels may be ' + 'If true, the x axis wont overflow and labels may be ' +
@ -233,6 +242,7 @@ export const fields = {
include_series: { include_series: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Include Series', label: 'Include Series',
renderTrigger: true,
default: false, default: false,
description: 'Include series name as an axis', description: 'Include series name as an axis',
}, },
@ -276,7 +286,9 @@ export const fields = {
type: 'SelectField', type: 'SelectField',
multi: true, multi: true,
label: 'Columns', label: 'Columns',
choices: [], mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.gb_cols : [],
}),
default: [], default: [],
description: 'One or many fields to pivot as columns', description: 'One or many fields to pivot as columns',
}, },
@ -408,28 +420,28 @@ export const fields = {
granularity_sqla: { granularity_sqla: {
type: 'SelectField', type: 'SelectField',
label: 'Time Column', label: 'Time Column',
default: null, default: field => field.choices && field.choices.length > 0 ? field.choices[0][0] : null,
description: 'The time column for the visualization. Note that you ' + description: 'The time column for the visualization. Note that you ' +
'can define arbitrary expression that return a DATETIME ' + 'can define arbitrary expression that return a DATETIME ' +
'column in the table or. Also note that the ' + 'column in the table or. Also note that the ' +
'filter below is applied against this column or ' + 'filter below is applied against this column or ' +
'expression', 'expression',
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.all_cols : [], choices: (state.datasource) ? state.datasource.granularity_sqla : [],
}), }),
}, },
time_grain_sqla: { time_grain_sqla: {
type: 'SelectField', type: 'SelectField',
label: 'Time Grain', label: 'Time Grain',
default: 'Time Column', default: field => field.choices && field.choices.length ? field.choices[0][0] : null,
description: 'The time granularity for the visualization. This ' + description: 'The time granularity for the visualization. This ' +
'applies a date transformation to alter ' + 'applies a date transformation to alter ' +
'your time column and defines a new time granularity. ' + 'your time column and defines a new time granularity. ' +
'The options here are defined on a per database ' + 'The options here are defined on a per database ' +
'engine basis in the Superset source code.', 'engine basis in the Superset source code.',
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.time_grain_sqla : [], choices: (state.datasource) ? state.datasource.time_grain_sqla : null,
}), }),
}, },
@ -605,7 +617,7 @@ export const fields = {
default: null, default: null,
description: 'Metric assigned to the [X] axis', description: 'Metric assigned to the [X] axis',
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.gb_cols : [], choices: (state.datasource) ? state.datasource.metrics_combo : [],
}), }),
}, },
@ -639,12 +651,14 @@ export const fields = {
x_axis_label: { x_axis_label: {
type: 'TextField', type: 'TextField',
label: 'X Axis Label', label: 'X Axis Label',
renderTrigger: true,
default: '', default: '',
}, },
y_axis_label: { y_axis_label: {
type: 'TextField', type: 'TextField',
label: 'Y Axis Label', label: 'Y Axis Label',
renderTrigger: true,
default: '', default: '',
}, },
@ -712,6 +726,7 @@ export const fields = {
type: 'SelectField', type: 'SelectField',
freeForm: true, freeForm: true,
label: 'X axis format', label: 'X axis format',
renderTrigger: true,
default: 'smart_date', default: 'smart_date',
choices: TIME_STAMP_OPTIONS, choices: TIME_STAMP_OPTIONS,
description: D3_FORMAT_DOCS, description: D3_FORMAT_DOCS,
@ -721,6 +736,7 @@ export const fields = {
type: 'SelectField', type: 'SelectField',
freeForm: true, freeForm: true,
label: 'Y axis format', label: 'Y axis format',
renderTrigger: true,
default: '.3s', default: '.3s',
choices: D3_TIME_FORMAT_OPTIONS, choices: D3_TIME_FORMAT_OPTIONS,
description: D3_FORMAT_DOCS, description: D3_FORMAT_DOCS,
@ -754,6 +770,7 @@ export const fields = {
line_interpolation: { line_interpolation: {
type: 'SelectField', type: 'SelectField',
label: 'Line Style', label: 'Line Style',
renderTrigger: true,
choices: formatSelectOptions(['linear', 'basis', 'cardinal', choices: formatSelectOptions(['linear', 'basis', 'cardinal',
'monotone', 'step-before', 'step-after']), 'monotone', 'step-before', 'step-after']),
default: 'linear', default: 'linear',
@ -782,6 +799,7 @@ export const fields = {
pandas_aggfunc: { pandas_aggfunc: {
type: 'SelectField', type: 'SelectField',
label: 'Aggregation function', label: 'Aggregation function',
clearable: false,
choices: formatSelectOptions([ choices: formatSelectOptions([
'sum', 'sum',
'mean', 'mean',
@ -815,6 +833,7 @@ export const fields = {
show_brush: { show_brush: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Range Filter', label: 'Range Filter',
renderTrigger: true,
default: false, default: false,
description: 'Whether to display the time range interactive selector', description: 'Whether to display the time range interactive selector',
}, },
@ -836,6 +855,7 @@ export const fields = {
include_search: { include_search: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Search Box', label: 'Search Box',
renderTrigger: true,
default: false, default: false,
description: 'Whether to include a client side search box', description: 'Whether to include a client side search box',
}, },
@ -851,12 +871,14 @@ export const fields = {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Show Bubbles', label: 'Show Bubbles',
default: false, default: false,
renderTrigger: true,
description: 'Whether to display bubbles on top of countries', description: 'Whether to display bubbles on top of countries',
}, },
show_legend: { show_legend: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Legend', label: 'Legend',
renderTrigger: true,
default: true, default: true,
description: 'Whether to display the legend (toggles)', description: 'Whether to display the legend (toggles)',
}, },
@ -864,6 +886,7 @@ export const fields = {
x_axis_showminmax: { x_axis_showminmax: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'X bounds', label: 'X bounds',
renderTrigger: true,
default: true, default: true,
description: 'Whether to display the min and max values of the X axis', description: 'Whether to display the min and max values of the X axis',
}, },
@ -871,6 +894,7 @@ export const fields = {
rich_tooltip: { rich_tooltip: {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Rich Tooltip', label: 'Rich Tooltip',
renderTrigger: true,
default: true, default: true,
description: 'The rich tooltip shows a list of all series for that ' + description: 'The rich tooltip shows a list of all series for that ' +
'point in time', 'point in time',
@ -880,6 +904,7 @@ export const fields = {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Y Axis Zero', label: 'Y Axis Zero',
default: false, default: false,
renderTrigger: true,
description: 'Force the Y axis to start at 0 instead of the minimum value', description: 'Force the Y axis to start at 0 instead of the minimum value',
}, },
@ -887,6 +912,7 @@ export const fields = {
type: 'CheckboxField', type: 'CheckboxField',
label: 'Y Log Scale', label: 'Y Log Scale',
default: false, default: false,
renderTrigger: true,
description: 'Use a log scale for the Y axis', description: 'Use a log scale for the Y axis',
}, },
@ -894,6 +920,7 @@ export const fields = {
type: 'CheckboxField', type: 'CheckboxField',
label: 'X Log Scale', label: 'X Log Scale',
default: false, default: false,
renderTrigger: true,
description: 'Use a log scale for the X axis', description: 'Use a log scale for the X axis',
}, },
@ -1005,12 +1032,12 @@ export const fields = {
point_radius: { point_radius: {
type: 'SelectField', type: 'SelectField',
label: 'Point Radius', label: 'Point Radius',
default: null, default: 'Auto',
description: 'The radius of individual points (ones that are not in a cluster). ' + description: 'The radius of individual points (ones that are not in a cluster). ' +
'Either a numerical column or `Auto`, which scales the point based ' + 'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster', 'on the largest cluster',
mapStateToProps: (state) => ({ mapStateToProps: (state) => ({
choices: state.fields.point_radius.choices, choices: [].concat([['Auto', 'Auto']], state.datasource.all_cols),
}), }),
}, },
@ -1133,34 +1160,24 @@ export const fields = {
datasource: state.datasource, datasource: state.datasource,
}), }),
}, },
having_filters: {
type: 'FilterField',
label: '',
default: [],
description: '',
mapStateToProps: (state) => ({
choices: (state.datasource) ? state.datasource.metrics_combo
.concat(state.datasource.filterable_cols) : [],
datasource: state.datasource,
}),
},
slice_id: {
type: 'HiddenField',
label: 'Slice ID',
hidden: true,
description: 'The id of the active slice',
},
}; };
export default fields; export default fields;
// Control Panel fields that re-render chart without need for 'Query button'
export const autoQueryFields = [
'datasource',
'viz_type',
'bar_stacked',
'show_markers',
'show_bar_value',
'order_bars',
'show_controls',
'reduce_x_ticks',
'include_series',
'pie_label_type',
'show_brush',
'include_search',
'show_bubbles',
'show_legend',
'x_axis_showminmax',
'rich_tooltip',
'y_axis_zero',
'y_log_scale',
'x_log_scale',
'donut',
'labels_outside',
'contribution',
'size',
'row_limit',
'max_bubble_size',
];

View File

@ -1,56 +1,111 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import { sectionsToRender } from './visTypes';
import fields from './fields'; import fields from './fields';
import visTypes, { sectionsToRender } from './visTypes';
export function defaultFormData(vizType = 'table', datasourceType = 'table') { export function getFormDataFromFields(fieldsState) {
const data = { const formData = {};
slice_name: null, Object.keys(fieldsState).forEach(fieldName => {
slice_id: null, formData[fieldName] = fieldsState[fieldName].value;
datasource_name: null,
filters: [],
};
const sections = sectionsToRender(vizType, datasourceType);
sections.forEach((section) => {
section.fieldSetRows.forEach((fieldSetRow) => {
fieldSetRow.forEach((k) => {
data[k] = fields[k].default;
});
});
}); });
return data; return formData;
} }
export function defaultViz(vizType, datasourceType = 'table') { export function getFieldNames(vizType, datasourceType) {
return { const fieldNames = [];
cached_key: null, sectionsToRender(vizType, datasourceType).forEach(
cached_timeout: null, section => section.fieldSetRows.forEach(
cached_dttm: null, fsr => fsr.forEach(
column_formats: null, f => fieldNames.push(f))));
csv_endpoint: null, return fieldNames;
is_cached: false,
data: [],
form_data: defaultFormData(vizType, datasourceType),
json_endpoint: null,
query: null,
standalone_endpoint: null,
};
} }
export function initialState(vizType = 'table', datasourceType = 'table') { export function getFieldsState(state, form_data) {
return { /*
dashboards: [], * Gets a new fields object to put in the state. The fields object
isDatasourceMetaLoading: false, * is similar to the configuration field with only the fields
datasources: null, * related to the current viz_type, materializes mapStateToProps functions,
datasource_type: null, * adds value keys coming from form_data passed here. This can't be an action creator
filterColumnOpts: [], * just yet because it's used in both the explore and dashboard views.
fields, * */
viz: defaultViz(vizType, datasourceType),
isStarred: false, // Getting a list of active field names for the current viz
}; const formData = Object.assign({}, form_data);
const vizType = formData.viz_type || 'table';
const fieldNames = getFieldNames(vizType, state.datasource.type);
const viz = visTypes[vizType];
const fieldOverrides = viz.fieldOverrides || {};
const fieldsState = {};
fieldNames.forEach((k) => {
const field = Object.assign({}, fields[k], fieldOverrides[k]);
if (field.mapStateToProps) {
Object.assign(field, field.mapStateToProps(state));
delete field.mapStateToProps;
}
// If the value is not valid anymore based on choices, clear it
if (field.type === 'SelectField' && field.choices && k !== 'datasource' && formData[k]) {
const choiceValues = field.choices.map(c => c[0]);
if (field.multi && formData[k].length > 0 && choiceValues.indexOf(formData[k][0]) < 0) {
delete formData[k];
} else if (!field.multi && !field.freeForm && choiceValues.indexOf(formData[k]) < 0) {
delete formData[k];
}
}
// Removing invalid filters that point to a now inexisting column
if (field.type === 'FilterField' && field.choices) {
const choiceValues = field.choices.map(c => c[0]);
formData[k] = field.value.filter(flt => choiceValues.indexOf(flt.col) > 0);
}
if (typeof field.default === 'function') {
field.default = field.default(field);
}
field.value = formData[k] !== undefined ? formData[k] : field.default;
fieldsState[k] = field;
});
return fieldsState;
}
export function applyDefaultFormData(form_data) {
const datasourceType = form_data.datasource.split('__')[1];
const vizType = form_data.viz_type || 'table';
const viz = visTypes[vizType];
const fieldNames = getFieldNames(vizType, datasourceType);
const fieldOverrides = viz.fieldOverrides || {};
const formData = {};
fieldNames.forEach(k => {
const field = Object.assign({}, fields[k]);
if (fieldOverrides[k]) {
Object.assign(field, fieldOverrides[k]);
}
if (form_data[k] === undefined) {
if (typeof field.default === 'function') {
formData[k] = field.default(fields[k]);
} else {
formData[k] = field.default;
}
} else {
formData[k] = form_data[k];
}
});
return formData;
} }
// Control Panel fields that re-render chart without need for 'Query button'
export const autoQueryFields = [ export const autoQueryFields = [
'datasource', 'datasource',
'viz_type', 'viz_type',
]; ];
const defaultFields = Object.assign({}, fields);
Object.keys(fields).forEach((f) => {
defaultFields[f].value = fields[f].default;
});
const defaultState = {
fields: defaultFields,
form_data: getFormDataFromFields(defaultFields),
};
export { defaultFields, defaultState };

View File

@ -12,6 +12,7 @@ export const commonControlPanelSections = {
fieldSetRows: [ fieldSetRows: [
['datasource'], ['datasource'],
['viz_type'], ['viz_type'],
['slice_id'],
], ],
}, },
sqlaTimeSeries: { sqlaTimeSeries: {
@ -60,15 +61,13 @@ export const commonControlPanelSections = {
'Leave the value field empty to filter empty strings or nulls' + 'Leave the value field empty to filter empty strings or nulls' +
'For filters with comma in values, wrap them in single quotes' + 'For filters with comma in values, wrap them in single quotes' +
"as in <NY, 'Tahoe, CA', DC>", "as in <NY, 'Tahoe, CA', DC>",
prefix: 'flt',
fieldSetRows: [['filters']], fieldSetRows: [['filters']],
}, },
{ {
label: 'Result Filters', label: 'Result Filters',
description: 'The filters to apply after post-aggregation.' + description: 'The filters to apply after post-aggregation.' +
'Leave the value field empty to filter empty strings or nulls', 'Leave the value field empty to filter empty strings or nulls',
prefix: 'having', fieldSetRows: [['having_filters']],
fieldSetRows: [['filters']],
}, },
], ],
}; };
@ -250,8 +249,7 @@ const visTypes = {
label: 'Options', label: 'Options',
fieldSetRows: [ fieldSetRows: [
['table_timestamp_format'], ['table_timestamp_format'],
['row_limit'], ['row_limit', 'page_length'],
['page_length'],
['include_search', 'table_filter'], ['include_search', 'table_filter'],
], ],
}, },
@ -433,6 +431,7 @@ const visTypes = {
}, },
big_number_total: { big_number_total: {
label: 'Big Number',
controlPanelSections: [ controlPanelSections: [
{ {
label: null, label: null,
@ -758,12 +757,11 @@ export function sectionsToRender(vizType, datasourceType) {
const { datasourceAndVizType, sqlClause, filters } = commonControlPanelSections; const { datasourceAndVizType, sqlClause, filters } = commonControlPanelSections;
const filtersToRender = const filtersToRender =
datasourceType === 'table' ? filters[0] : filters; datasourceType === 'table' ? filters[0] : filters;
const sections = [].concat( return [].concat(
datasourceAndVizType, datasourceAndVizType,
timeSection, timeSection,
viz.controlPanelSections, viz.controlPanelSections,
sqlClause, sqlClause,
filtersToRender filtersToRender
); );
return sections;
} }

View File

@ -4,6 +4,8 @@ const utils = require('./utils');
// vis sources // vis sources
/* eslint camel-case: 0 */ /* eslint camel-case: 0 */
import vizMap from '../../visualizations/main.js'; import vizMap from '../../visualizations/main.js';
import { getExploreUrl } from '../explorev2/exploreUtils';
import { applyDefaultFormData } from '../explorev2/stores/store';
/* eslint wrap-iife: 0*/ /* eslint wrap-iife: 0*/
const px = function () { const px = function () {
@ -55,12 +57,14 @@ const px = function () {
} }
const Slice = function (data, controller) { const Slice = function (data, controller) {
let timer; let timer;
const token = $('#' + data.token); const token = $('#token_' + data.slice_id);
const containerId = data.token + '_con'; const containerId = 'con_' + data.slice_id;
const selector = '#' + containerId; const selector = '#' + containerId;
const container = $(selector); const container = $(selector);
const sliceId = data.slice_id; const sliceId = data.slice_id;
const origJsonEndpoint = data.json_endpoint; const formData = applyDefaultFormData(data.form_data);
const jsonEndpoint = getExploreUrl(formData, 'table', 'json');
const origJsonEndpoint = jsonEndpoint;
let dttm = 0; let dttm = 0;
const stopwatch = function () { const stopwatch = function () {
dttm += 10; dttm += 10;
@ -70,12 +74,13 @@ const px = function () {
let qrystr = ''; let qrystr = '';
slice = { slice = {
data, data,
formData,
container, container,
containerId, containerId,
selector, selector,
querystring() { querystring() {
const parser = document.createElement('a'); const parser = document.createElement('a');
parser.href = data.json_endpoint; parser.href = jsonEndpoint;
if (controller.type === 'dashboard') { if (controller.type === 'dashboard') {
parser.href = origJsonEndpoint; parser.href = origJsonEndpoint;
let flts = controller.effectiveExtraFilters(sliceId); let flts = controller.effectiveExtraFilters(sliceId);
@ -100,7 +105,7 @@ const px = function () {
}, },
jsonEndpoint() { jsonEndpoint() {
const parser = document.createElement('a'); const parser = document.createElement('a');
parser.href = data.json_endpoint; parser.href = jsonEndpoint;
let endpoint = parser.pathname + this.querystring(); let endpoint = parser.pathname + this.querystring();
if (endpoint.charAt(0) !== '/') { if (endpoint.charAt(0) !== '/') {
// Known issue for IE <= 11: // Known issue for IE <= 11:
@ -114,8 +119,11 @@ const px = function () {
d3format(col, number) { d3format(col, number) {
// uses the utils memoized d3format function and formats based on // uses the utils memoized d3format function and formats based on
// column level defined preferences // column level defined preferences
const format = data.column_formats[col]; if (data.column_formats) {
return utils.d3format(format, number); const format = data.column_formats[col];
return utils.d3format(format, number);
}
return utils.d3format('.3s', number);
}, },
/* eslint no-shadow: 0 */ /* eslint no-shadow: 0 */
always(data) { always(data) {
@ -224,7 +232,7 @@ const px = function () {
$('#timer').addClass('label-warning'); $('#timer').addClass('label-warning');
$.getJSON(this.jsonEndpoint(), queryResponse => { $.getJSON(this.jsonEndpoint(), queryResponse => {
try { try {
vizMap[data.form_data.viz_type](this, queryResponse); vizMap[formData.viz_type](this, queryResponse);
this.done(queryResponse); this.done(queryResponse);
} catch (e) { } catch (e) {
this.error('An error occurred while rendering the visualization: ' + e); this.error('An error occurred while rendering the visualization: ' + e);

View File

@ -133,14 +133,14 @@ export function formatSelectOptionsForRange(start, end) {
// returns [[1,1], [2,2], [3,3], [4,4], [5,5]] // returns [[1,1], [2,2], [3,3], [4,4], [5,5]]
const options = []; const options = [];
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
options.push([i.toString(), i.toString()]); options.push([i, i.toString()]);
} }
return options; return options;
} }
export function formatSelectOptions(options) { export function formatSelectOptions(options) {
return options.map((opt) => return options.map((opt) =>
[opt.toString(), opt.toString()] [opt, opt.toString()]
); );
} }

View File

@ -1,17 +0,0 @@
const $ = window.$ = require('jquery');
/* eslint no-unused-vars: 0 */
const jQuery = window.jQuery = $;
const px = require('./modules/superset.js');
const utils = require('./modules/utils.js');
require('bootstrap');
const standaloneController = Object.assign(
{}, utils.controllerInterface, { type: 'standalone' });
$(document).ready(function () {
const data = $('.slice').data('slice');
const slice = px.Slice(data, standaloneController);
slice.render();
slice.bindResizeToWindowResize();
});

View File

@ -1,5 +0,0 @@
require('../node_modules/select2/select2.css');
require('../node_modules/select2-bootstrap-css/select2-bootstrap.min.css');
require('../node_modules/jquery-ui/themes/base/jquery-ui.css');
require('select2');
require('../vendor/select2.sortable.js');

View File

@ -0,0 +1,25 @@
/* eslint no-console: 0 */
import fs from 'fs';
import path from 'path';
import { fields } from './explorev2/stores/fields';
function exportFile(fileLocation, content) {
fs.writeFile(fileLocation, content, function (err) {
if (err) {
console.log(`File ${fileLocation} was not saved... :(`);
} else {
console.log(`File ${fileLocation} was saved!`);
}
});
}
function main() {
const APP_DIR = path.resolve(__dirname, './');
const dir = APP_DIR + '/../dist/';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const blob = { fields };
exportFile(APP_DIR + '/../backendSync.json', JSON.stringify(blob, null, 2));
}
main();

View File

@ -4,6 +4,7 @@ cd "$(dirname "$0")"
npm --version npm --version
node --version node --version
npm install npm install
npm run sync-backend
npm run lint npm run lint
npm run test npm run test
npm run build npm run build

View File

@ -13,7 +13,8 @@
"dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map", "dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map",
"prod": "NODE_ENV=production node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js -p --colors --progress", "prod": "NODE_ENV=production node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js -p --colors --progress",
"build": "NODE_ENV=production webpack --colors --progress", "build": "NODE_ENV=production webpack --colors --progress",
"lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx ." "lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx .",
"sync-backend": "babel-node --presets es2015 javascripts/syncBackend.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -40,7 +41,7 @@
"autobind-decorator": "^1.3.3", "autobind-decorator": "^1.3.3",
"bootstrap": "^3.3.6", "bootstrap": "^3.3.6",
"bootstrap-datepicker": "^1.6.0", "bootstrap-datepicker": "^1.6.0",
"brace": "^0.8.0", "brace": "^0.9.1",
"brfs": "^1.4.3", "brfs": "^1.4.3",
"cal-heatmap": "3.6.2", "cal-heatmap": "3.6.2",
"classnames": "^2.2.5", "classnames": "^2.2.5",
@ -57,7 +58,6 @@
"immutability-helper": "^2.0.0", "immutability-helper": "^2.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",
"jquery": "^2.2.1", "jquery": "^2.2.1",
"jquery-ui": "1.10.5",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mapbox-gl": "^0.26.0", "mapbox-gl": "^0.26.0",
"moment": "^2.14.1", "moment": "^2.14.1",
@ -65,7 +65,7 @@
"mustache": "^2.2.1", "mustache": "^2.2.1",
"nvd3": "1.8.5", "nvd3": "1.8.5",
"react": "^15.3.2", "react": "^15.3.2",
"react-ace": "^3.4.1", "react-ace": "^4.1.5",
"react-bootstrap": "^0.30.3", "react-bootstrap": "^0.30.3",
"react-bootstrap-table": "^2.3.8", "react-bootstrap-table": "^2.3.8",
"react-dom": "^15.3.2", "react-dom": "^15.3.2",
@ -73,21 +73,19 @@
"react-gravatar": "^2.6.1", "react-gravatar": "^2.6.1",
"react-grid-layout": "^0.13.1", "react-grid-layout": "^0.13.1",
"react-map-gl": "^1.7.0", "react-map-gl": "^1.7.0",
"react-redux": "^4.4.5", "react-redux": "^5.0.2",
"react-resizable": "^1.3.3", "react-resizable": "^1.3.3",
"react-select": "^1.0.0-rc.2", "react-select": "^1.0.0-rc.2",
"react-syntax-highlighter": "^2.3.0", "react-syntax-highlighter": "^5.0.0",
"reactable": "^0.14.0", "reactable": "^0.14.0",
"redux": "^3.5.2", "redux": "^3.5.2",
"redux-localstorage": "^0.4.1", "redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0", "redux-thunk": "^2.1.0",
"select2": "3.5",
"select2-bootstrap-css": "^1.4.6",
"shortid": "^2.2.6", "shortid": "^2.2.6",
"style-loader": "^0.13.0", "style-loader": "^0.13.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40", "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"topojson": "^1.6.22", "topojson": "^1.6.22",
"victory": "^0.12.1", "victory": "^0.17.0",
"viewport-mercator-project": "^2.1.0" "viewport-mercator-project": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,18 +1,18 @@
import { it, describe } from 'mocha'; import { it, describe } from 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import * as actions from '../../../javascripts/explorev2/actions/exploreActions'; import * as actions from '../../../javascripts/explorev2/actions/exploreActions';
import { initialState } from '../../../javascripts/explorev2/stores/store'; import { defaultState } from '../../../javascripts/explorev2/stores/store';
import { exploreReducer } from '../../../javascripts/explorev2/reducers/exploreReducer'; import { exploreReducer } from '../../../javascripts/explorev2/reducers/exploreReducer';
describe('reducers', () => { describe('reducers', () => {
it('sets correct field value given a key and value', () => { it('sets correct field value given a key and value', () => {
const newState = exploreReducer( const newState = exploreReducer(
initialState('dist_bar'), actions.setFieldValue('x_axis_label', 'x')); defaultState, actions.setFieldValue('x_axis_label', 'x', []));
expect(newState.viz.form_data.x_axis_label).to.equal('x'); expect(newState.fields.x_axis_label.value).to.equal('x');
}); });
it('setFieldValue works as expected with a checkbox', () => { it('setFieldValue works as expected with a checkbox', () => {
const newState = exploreReducer(initialState('dist_bar'), const newState = exploreReducer(defaultState,
actions.setFieldValue('show_legend', true)); actions.setFieldValue('show_legend', true, []));
expect(newState.viz.form_data.show_legend).to.equal(true); expect(newState.fields.show_legend.value).to.equal(true);
}); });
}); });

View File

@ -3,24 +3,19 @@ import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha'; import { describe, it, beforeEach } from 'mocha';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Panel } from 'react-bootstrap'; import { Panel } from 'react-bootstrap';
import { defaultFormData, initialState } from '../../../../javascripts/explorev2/stores/store'; import { getFormDataFromFields, defaultFields }
from '../../../../javascripts/explorev2/stores/store';
import { import {
ControlPanelsContainer, ControlPanelsContainer,
} from '../../../../javascripts/explorev2/components/ControlPanelsContainer'; } from '../../../../javascripts/explorev2/components/ControlPanelsContainer';
import { fields } from '../../../../javascripts/explorev2/stores/fields';
const defaultProps = { const defaultProps = {
datasource_id: 1, datasource_type: 'table',
datasource_type: 'type', actions: {},
exploreState: initialState(), fields: defaultFields,
form_data: defaultFormData(), form_data: getFormDataFromFields(defaultFields),
fields, isDatasourceMetaLoading: false,
actions: { exploreState: {},
fetchFieldOptions: () => {
// noop
},
},
}; };
describe('ControlPanelsContainer', () => { describe('ControlPanelsContainer', () => {

View File

@ -2,7 +2,7 @@ import React from 'react';
import { expect } from 'chai'; import { expect } from 'chai';
import { describe, it } from 'mocha'; import { describe, it } from 'mocha';
import DisplayQueryButton from '../../../../javascripts/explore/components/DisplayQueryButton'; import DisplayQueryButton from '../../../../javascripts/explorev2/components/DisplayQueryButton';
describe('DisplayQueryButton', () => { describe('DisplayQueryButton', () => {
const defaultProps = { const defaultProps = {

View File

@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
import { shallow, mount } from 'enzyme'; import { shallow, mount } from 'enzyme';
import { OverlayTrigger } from 'react-bootstrap'; import { OverlayTrigger } from 'react-bootstrap';
import EmbedCodeButton from '../../../../javascripts/explore/components/EmbedCodeButton'; import EmbedCodeButton from '../../../../javascripts/explorev2/components/EmbedCodeButton';
describe('EmbedCodeButton', () => { describe('EmbedCodeButton', () => {
const defaultProps = { const defaultProps = {

View File

@ -2,8 +2,8 @@ import React from 'react';
import { expect } from 'chai'; import { expect } from 'chai';
import { describe, it } from 'mocha'; import { describe, it } from 'mocha';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import ExploreActionButtons from
import ExploreActionButtons from '../../../../javascripts/explore/components/ExploreActionButtons'; '../../../../javascripts/explorev2/components/ExploreActionButtons';
describe('ExploreActionButtons', () => { describe('ExploreActionButtons', () => {
const defaultProps = { const defaultProps = {

View File

@ -7,6 +7,7 @@ import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha'; import { describe, it, beforeEach } from 'mocha';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import Filter from '../../../../javascripts/explorev2/components/Filter'; import Filter from '../../../../javascripts/explorev2/components/Filter';
import SelectField from '../../../../javascripts/explorev2/components/SelectField';
const defaultProps = { const defaultProps = {
choices: ['country_name'], choices: ['country_name'],
@ -16,8 +17,6 @@ const defaultProps = {
// noop // noop
}, },
filter: { filter: {
id: 1,
prefix: 'flt',
col: null, col: null,
op: 'in', op: 'in',
value: '', value: '',
@ -45,7 +44,7 @@ describe('Filter', () => {
it('renders two selects, one button and one input', () => { it('renders two selects, one button and one input', () => {
expect(wrapper.find(Select)).to.have.lengthOf(2); expect(wrapper.find(Select)).to.have.lengthOf(2);
expect(wrapper.find(Button)).to.have.lengthOf(1); expect(wrapper.find(Button)).to.have.lengthOf(1);
expect(wrapper.find('input')).to.have.lengthOf(1); expect(wrapper.find(SelectField)).to.have.lengthOf(1);
}); });
it('calls changeFilter when select is changed', () => { it('calls changeFilter when select is changed', () => {
@ -53,8 +52,8 @@ describe('Filter', () => {
selectCol.simulate('change', { value: 'col' }); selectCol.simulate('change', { value: 'col' });
const selectOp = wrapper.find('#select-op'); const selectOp = wrapper.find('#select-op');
selectOp.simulate('change', { value: 'in' }); selectOp.simulate('change', { value: 'in' });
const input = wrapper.find('input'); const selectVal = wrapper.find(SelectField);
input.simulate('change', { target: { value: 'x' } }); selectVal.simulate('change', { value: 'x' });
expect(defaultProps.changeFilter).to.have.property('callCount', 3); expect(defaultProps.changeFilter).to.have.property('callCount', 3);
}); });
}); });

View File

@ -4,7 +4,7 @@ import { expect } from 'chai';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import sinon from 'sinon'; import sinon from 'sinon';
import QueryAndSaveButtons from '../../../../javascripts/explore/components/QueryAndSaveBtns'; import QueryAndSaveButtons from '../../../../javascripts/explorev2/components/QueryAndSaveBtns';
import Button from '../../../../javascripts/components/Button'; import Button from '../../../../javascripts/components/Button';
describe('QueryAndSaveButtons', () => { describe('QueryAndSaveButtons', () => {
@ -36,7 +36,7 @@ describe('QueryAndSaveButtons', () => {
}); });
it('calls onQuery when query button is clicked', () => { it('calls onQuery when query button is clicked', () => {
const queryButton = wrapper.find('#query_button'); const queryButton = wrapper.find('.query');
queryButton.simulate('click'); queryButton.simulate('click');
expect(defaultProps.onQuery.called).to.eql(true); expect(defaultProps.onQuery.called).to.eql(true);
}); });

View File

@ -14,10 +14,8 @@ const defaultProps = {
saveSlice: sinon.spy(), saveSlice: sinon.spy(),
}, },
form_data: defaultFormData, form_data: defaultFormData,
datasource_id: 1,
datasource_name: 'birth_names',
datasource_type: 'table',
user_id: 1, user_id: 1,
slice: {},
}; };
describe('SaveModal', () => { describe('SaveModal', () => {

View File

@ -2,7 +2,7 @@ import React from 'react';
import { expect } from 'chai'; import { expect } from 'chai';
import { describe, it } from 'mocha'; import { describe, it } from 'mocha';
import URLShortLinkButton from '../../../../javascripts/explore/components/URLShortLinkButton'; import URLShortLinkButton from '../../../../javascripts/explorev2/components/URLShortLinkButton';
describe('URLShortLinkButton', () => { describe('URLShortLinkButton', () => {
const defaultProps = { const defaultProps = {

View File

@ -1,146 +0,0 @@
/**
* jQuery Select2 Sortable
* - enable select2 to be sortable via normal select element
*
* author : Vafour
* modified : Kevin Provance (kprovance)
* inspired by : jQuery Chosen Sortable (https://github.com/mrhenry/jquery-chosen-sortable)
* License : GPL
*/
(function ($) {
$.fn.extend({
select2SortableOrder: function () {
var $this = this.filter('[multiple]');
$this.each(function () {
var $select = $(this);
// skip elements not select2-ed
if (typeof ($select.data('select2')) !== 'object') {
return false;
}
var $select2 = $select.siblings('.select2-container');
var sorted;
// Opt group names
var optArr = [];
$select.find('optgroup').each(function(idx, val) {
optArr.push (val);
});
$select.find('option').each(function(idx, val) {
var groupName = $(this).parent('optgroup').prop('label');
var optVal = this;
if (groupName === undefined) {
if (this.value !== '' && !this.selected) {
optArr.push (optVal);
}
}
});
sorted = $($select2.find('.select2-choices li[class!="select2-search-field"]').map(function () {
if (!this) {
return undefined;
}
var id = $(this).data('select2Data').id;
return $select.find('option[value="' + id + '"]')[0];
}));
sorted.push.apply(sorted, optArr);
$select.children().remove();
$select.append(sorted);
});
return $this;
},
select2Sortable: function () {
var args = Array.prototype.slice.call(arguments, 0);
var $this = this.filter('[multiple]'),
validMethods = ['destroy'];
if (args.length === 0 || typeof (args[0]) === 'object') {
var defaultOptions = {
bindOrder: 'formSubmit', // or sortableStop
sortableOptions: {
placeholder: 'ui-state-highlight',
items: 'li:not(.select2-search-field)',
tolerance: 'pointer'
}
};
var options = $.extend(defaultOptions, args[0]);
// Init select2 only if not already initialized to prevent select2 configuration loss
if (typeof ($this.data('select2')) !== 'object') {
$this.select2();
}
$this.each(function () {
var $select = $(this)
var $select2choices = $select.siblings('.select2-container').find('.select2-choices');
// Init jQuery UI Sortable
$select2choices.sortable(options.sortableOptions);
switch (options.bindOrder) {
case 'sortableStop':
// apply options ordering in sortstop event
$select2choices.on("sortstop.select2sortable", function (event, ui) {
$select.select2SortableOrder();
});
$select.on('change', function (e) {
$(this).select2SortableOrder();
});
break;
default:
// apply options ordering in form submit
$select.closest('form').unbind('submit.select2sortable').on('submit.select2sortable', function () {
$select.select2SortableOrder();
});
break;
}
});
}
else if (typeof (args[0] === 'string')) {
if ($.inArray(args[0], validMethods) == -1) {
throw "Unknown method: " + args[0];
}
if (args[0] === 'destroy') {
$this.select2SortableDestroy();
}
}
return $this;
},
select2SortableDestroy: function () {
var $this = this.filter('[multiple]');
$this.each(function () {
var $select = $(this)
var $select2choices = $select.parent().find('.select2-choices');
// unbind form submit event
$select.closest('form').unbind('submit.select2sortable');
// unbind sortstop event
$select2choices.unbind("sortstop.select2sortable");
// destroy select2Sortable
$select2choices.sortable('destroy');
});
return $this;
}
});
}(jQuery));

View File

@ -7,8 +7,7 @@ function bigNumberVis(slice, payload) {
const div = d3.select(slice.selector); const div = d3.select(slice.selector);
// Define the percentage bounds that define color from red to green // Define the percentage bounds that define color from red to green
div.html(''); // reset div.html(''); // reset
const fd = slice.formData;
const fd = payload.form_data;
const json = payload.data; const json = payload.data;
const f = d3.format(fd.y_axis_format); const f = d3.format(fd.y_axis_format);

View File

@ -8,8 +8,9 @@ const directedForceVis = function (slice, json) {
const div = d3.select(slice.selector); const div = d3.select(slice.selector);
const width = slice.width(); const width = slice.width();
const height = slice.height() - 25; const height = slice.height() - 25;
const linkLength = json.form_data.link_length || 200; const fd = slice.formData;
const charge = json.form_data.charge || -500; const linkLength = fd.link_length || 200;
const charge = fd.charge || -500;
const links = json.data; const links = json.data;
const nodes = {}; const nodes = {};

View File

@ -113,13 +113,12 @@ function filterBox(slice, payload) {
d3token.selectAll('*').remove(); d3token.selectAll('*').remove();
// filter box should ignore the dashboard's filters // filter box should ignore the dashboard's filters
// TODO FUCK
// const url = slice.jsonEndpoint({ extraFilters: false }); // const url = slice.jsonEndpoint({ extraFilters: false });
const fd = payload.form_data; const fd = slice.formData;
const filtersChoices = {}; const filtersChoices = {};
// Making sure the ordering of the fields matches the setting in the // Making sure the ordering of the fields matches the setting in the
// dropdown as it may have been shuffled while serialized to json // dropdown as it may have been shuffled while serialized to json
payload.form_data.groupby.forEach((f) => { fd.groupby.forEach((f) => {
filtersChoices[f] = payload.data[f]; filtersChoices[f] = payload.data[f];
}); });
ReactDOM.render( ReactDOM.render(

View File

@ -57,7 +57,7 @@ function heatmapVis(slice, payload) {
slice.container.html(''); slice.container.html('');
const matrix = {}; const matrix = {};
const fd = payload.form_data; const fd = slice.formData;
adjustMargins(); adjustMargins();

View File

@ -126,7 +126,7 @@ function histogram(slice, payload) {
.classed('minor', true); .classed('minor', true);
}; };
const numBins = Number(payload.form_data.link_length) || 10; const numBins = Number(slice.formData.link_length) || 10;
div.selectAll('*').remove(); div.selectAll('*').remove();
draw(payload.data, numBins); draw(payload.data, numBins);
} }

View File

@ -191,7 +191,7 @@ const horizonChart = function () {
}; };
function horizonViz(slice, payload) { function horizonViz(slice, payload) {
const fd = payload.form_data; const fd = slice.formData;
const div = d3.select(slice.selector); const div = d3.select(slice.selector);
div.selectAll('*').remove(); div.selectAll('*').remove();
let extent; let extent;

View File

@ -1,8 +1,8 @@
const $ = require('jquery'); const $ = require('jquery');
function iframeWidget(slice, payload) { function iframeWidget(slice) {
$('#code').attr('rows', '15'); $('#code').attr('rows', '15');
const url = slice.render_template(payload.form_data.url); const url = slice.render_template(slice.formData.url);
slice.container.html('<iframe style="width:100%;"></iframe>'); slice.container.html('<iframe style="width:100%;"></iframe>');
const iframe = slice.container.find('iframe'); const iframe = slice.container.find('iframe');
iframe.css('height', slice.height()); iframe.css('height', slice.height());

View File

@ -96,7 +96,7 @@ function nvd3Vis(slice, payload) {
} }
let width = slice.width(); let width = slice.width();
const fd = payload.form_data; const fd = slice.formData;
const barchartWidth = function () { const barchartWidth = function () {
let bars; let bars;

View File

@ -8,7 +8,7 @@ require('./parallel_coordinates.css');
function parallelCoordVis(slice, payload) { function parallelCoordVis(slice, payload) {
$('#code').attr('rows', '15'); $('#code').attr('rows', '15');
const fd = payload.form_data; const fd = slice.formData;
const data = payload.data; const data = payload.data;
let cols = fd.metrics; let cols = fd.metrics;

View File

@ -10,7 +10,7 @@ dt(window, $);
module.exports = function (slice, payload) { module.exports = function (slice, payload) {
const container = slice.container; const container = slice.container;
const fd = payload.form_data; const fd = slice.formData;
container.html(payload.data); container.html(payload.data);
if (fd.groupby.length === 1) { if (fd.groupby.length === 1) {
const height = container.height(); const height = container.height();

View File

@ -341,8 +341,9 @@ function sunburstVis(slice, payload) {
}); });
let ext; let ext;
const fd = slice.formData;
if (rawData.form_data.metric !== rawData.form_data.secondary_metric) { if (fd.metric !== fd.secondary_metric) {
colorByCategory = false; colorByCategory = false;
ext = d3.extent(nodes, (d) => d.m2 / d.m1); ext = d3.extent(nodes, (d) => d.m2 / d.m1);
colorScale = d3.scale.linear() colorScale = d3.scale.linear()

View File

@ -16,7 +16,7 @@ function tableVis(slice, payload) {
let timestampFormatter; let timestampFormatter;
const data = payload.data; const data = payload.data;
const fd = payload.form_data; const fd = slice.formData;
// Removing metrics (aggregates) that are strings // Removing metrics (aggregates) that are strings
const realMetrics = []; const realMetrics = [];
for (const k in data.records[0]) { for (const k in data.records[0]) {

View File

@ -230,7 +230,7 @@ function treemap(slice, payload) {
const width = slice.width(); const width = slice.width();
const height = slice.height() / payload.data.length; const height = slice.height() / payload.data.length;
for (let i = 0, l = payload.data.length; i < l; i ++) { for (let i = 0, l = payload.data.length; i < l; i ++) {
_draw(payload.data[i], width, height, payload.form_data); _draw(payload.data[i], width, height, slice.formData);
} }
} }

View File

@ -6,11 +6,12 @@ import { category21 } from '../javascripts/modules/colors';
function wordCloudChart(slice, payload) { function wordCloudChart(slice, payload) {
const chart = d3.select(slice.selector); const chart = d3.select(slice.selector);
const data = payload.data; const data = payload.data;
const fd = slice.formData;
const range = [ const range = [
payload.form_data.size_from, fd.size_from,
payload.form_data.size_to, fd.size_to,
]; ];
const rotation = payload.form_data.rotation; const rotation = fd.rotation;
let fRotation; let fRotation;
if (rotation === 'square') { if (rotation === 'square') {
fRotation = () => ~~(Math.random() * 2) * 90; fRotation = () => ~~(Math.random() * 2) * 90;

View File

@ -11,7 +11,7 @@ function worldMapChart(slice, payload) {
container.css('height', slice.height()); container.css('height', slice.height());
div.selectAll('*').remove(); div.selectAll('*').remove();
const fd = payload.form_data; const fd = slice.formData;
// Ignore XXX's to get better normalization // Ignore XXX's to get better normalization
let data = payload.data.filter((d) => (d.country && d.country !== 'XXX')); let data = payload.data.filter((d) => (d.country && d.country !== 'XXX'));

View File

@ -15,10 +15,8 @@ const config = {
'css-theme': APP_DIR + '/javascripts/css-theme.js', 'css-theme': APP_DIR + '/javascripts/css-theme.js',
common: APP_DIR + '/javascripts/common.js', common: APP_DIR + '/javascripts/common.js',
dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/Dashboard.jsx'], dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/Dashboard.jsx'],
explore: ['babel-polyfill', APP_DIR + '/javascripts/explore/explore.jsx'],
explorev2: ['babel-polyfill', APP_DIR + '/javascripts/explorev2/index.jsx'], explorev2: ['babel-polyfill', APP_DIR + '/javascripts/explorev2/index.jsx'],
sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'], sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'],
standalone: ['babel-polyfill', APP_DIR + '/javascripts/standalone.js'],
welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome.js'], welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome.js'],
profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'], profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'],
}, },

View File

@ -80,12 +80,6 @@ def load_energy():
params=textwrap.dedent("""\ params=textwrap.dedent("""\
{ {
"collapsed_fieldsets": "", "collapsed_fieldsets": "",
"datasource_id": "3",
"datasource_name": "energy_usage",
"datasource_type": "table",
"flt_col_0": "source",
"flt_eq_0": "",
"flt_op_0": "in",
"groupby": [ "groupby": [
"source", "source",
"target" "target"
@ -111,12 +105,6 @@ def load_energy():
{ {
"charge": "-500", "charge": "-500",
"collapsed_fieldsets": "", "collapsed_fieldsets": "",
"datasource_id": "1",
"datasource_name": "energy_usage",
"datasource_type": "table",
"flt_col_0": "source",
"flt_eq_0": "",
"flt_op_0": "in",
"groupby": [ "groupby": [
"source", "source",
"target" "target"
@ -145,12 +133,6 @@ def load_energy():
"all_columns_y": "target", "all_columns_y": "target",
"canvas_image_rendering": "pixelated", "canvas_image_rendering": "pixelated",
"collapsed_fieldsets": "", "collapsed_fieldsets": "",
"datasource_id": "1",
"datasource_name": "energy_usage",
"datasource_type": "table",
"flt_col_0": "source",
"flt_eq_0": "",
"flt_op_0": "in",
"having": "", "having": "",
"linear_color_scheme": "blue_white_yellow", "linear_color_scheme": "blue_white_yellow",
"metric": "sum__value", "metric": "sum__value",
@ -202,9 +184,6 @@ def load_world_bank_health_n_pop():
defaults = { defaults = {
"compare_lag": "10", "compare_lag": "10",
"compare_suffix": "o10Y", "compare_suffix": "o10Y",
"datasource_id": "1",
"datasource_name": "birth_names",
"datasource_type": "table",
"limit": "25", "limit": "25",
"granularity": "year", "granularity": "year",
"groupby": [], "groupby": [],
@ -218,7 +197,7 @@ def load_world_bank_health_n_pop():
"country_fieldtype": "cca3", "country_fieldtype": "cca3",
"secondary_metric": "sum__SP_POP_TOTL", "secondary_metric": "sum__SP_POP_TOTL",
"entity": "country_code", "entity": "country_code",
"show_bubbles": "y", "show_bubbles": True,
} }
print("Creating slices") print("Creating slices")
@ -287,16 +266,20 @@ def load_world_bank_health_n_pop():
since="2011-01-01", since="2011-01-01",
until="2011-01-02", until="2011-01-02",
series="region", series="region",
limit="0", limit=0,
entity="country_name", entity="country_name",
x="sum__SP_RUR_TOTL_ZS", x="sum__SP_RUR_TOTL_ZS",
y="sum__SP_DYN_LE00_IN", y="sum__SP_DYN_LE00_IN",
size="sum__SP_POP_TOTL", size="sum__SP_POP_TOTL",
max_bubble_size="50", max_bubble_size="50",
flt_col_1="country_code", filters=[{
flt_op_1="not in", "col": "country_code",
flt_eq_1="TCA,MNP,DMA,MHL,MCO,SXM,CYM,TUV,IMY,KNA,ASM,ADO,AMA,PLW", "val": [
num_period_compare="10",)), "TCA", "MNP", "DMA", "MHL", "MCO", "SXM", "CYM",
"TUV", "IMY", "KNA", "ASM", "ADO", "AMA", "PLW",
],
"op": "not in"}],
)),
Slice( Slice(
slice_name="Rural Breakdown", slice_name="Rural Breakdown",
viz_type='sunburst', viz_type='sunburst',
@ -596,10 +579,6 @@ def load_birth_names():
defaults = { defaults = {
"compare_lag": "10", "compare_lag": "10",
"compare_suffix": "o10Y", "compare_suffix": "o10Y",
"datasource_id": "1",
"datasource_name": "birth_names",
"datasource_type": "table",
"flt_op_1": "in",
"limit": "25", "limit": "25",
"granularity": "ds", "granularity": "ds",
"groupby": [], "groupby": [],
@ -623,8 +602,12 @@ def load_birth_names():
params=get_slice_json( params=get_slice_json(
defaults, defaults,
groupby=['name'], groupby=['name'],
flt_col_1='gender', filters=[{
flt_eq_1="girl", row_limit=50)), 'col': 'gender',
'op': 'in',
'val': ['girl'],
}],
row_limit=50)),
Slice( Slice(
slice_name="Boys", slice_name="Boys",
viz_type='table', viz_type='table',
@ -633,8 +616,11 @@ def load_birth_names():
params=get_slice_json( params=get_slice_json(
defaults, defaults,
groupby=['name'], groupby=['name'],
flt_col_1='gender', filters=[{
flt_eq_1="boy", 'col': 'gender',
'op': 'in',
'val': ['boy'],
}],
row_limit=50)), row_limit=50)),
Slice( Slice(
slice_name="Participants", slice_name="Participants",
@ -660,9 +646,14 @@ def load_birth_names():
datasource_id=tbl.id, datasource_id=tbl.id,
params=get_slice_json( params=get_slice_json(
defaults, defaults,
flt_eq_1="other", viz_type="dist_bar", filters=[{
'col': 'state',
'op': 'not in',
'val': ['other'],
}],
viz_type="dist_bar",
metrics=['sum__sum_girls', 'sum__sum_boys'], metrics=['sum__sum_girls', 'sum__sum_boys'],
groupby=['state'], flt_op_1='not in', flt_col_1='state')), groupby=['state'])),
Slice( Slice(
slice_name="Trends", slice_name="Trends",
viz_type='line', viz_type='line',
@ -671,7 +662,7 @@ def load_birth_names():
params=get_slice_json( params=get_slice_json(
defaults, defaults,
viz_type="line", groupby=['name'], viz_type="line", groupby=['name'],
granularity='ds', rich_tooltip='y', show_legend='y')), granularity='ds', rich_tooltip=True, show_legend=True)),
Slice( Slice(
slice_name="Average and Sum Trends", slice_name="Average and Sum Trends",
viz_type='dual_line', viz_type='dual_line',
@ -726,7 +717,11 @@ def load_birth_names():
params=get_slice_json( params=get_slice_json(
defaults, defaults,
viz_type="big_number_total", granularity="ds", viz_type="big_number_total", granularity="ds",
flt_col_1='gender', flt_eq_1='girl', filters=[{
'col': 'gender',
'op': 'in',
'val': ['girl'],
}],
subheader='total female participants')), subheader='total female participants')),
] ]
for slc in slices: for slc in slices:
@ -851,10 +846,6 @@ def load_unicode_test_data():
tbl = obj tbl = obj
slice_data = { slice_data = {
"datasource_id": "3",
"datasource_name": "unicode_test",
"datasource_type": "table",
"flt_op_1": "in",
"granularity": "date", "granularity": "date",
"groupby": [], "groupby": [],
"metric": 'sum__value', "metric": 'sum__value',
@ -934,13 +925,11 @@ def load_random_time_series_data():
tbl = obj tbl = obj
slice_data = { slice_data = {
"datasource_id": "6",
"datasource_name": "random_time_series",
"datasource_type": "table",
"granularity": "day", "granularity": "day",
"row_limit": config.get("ROW_LIMIT"), "row_limit": config.get("ROW_LIMIT"),
"since": "1 year ago", "since": "1 year ago",
"until": "now", "until": "now",
"metric": "count",
"where": "", "where": "",
"viz_type": "cal_heatmap", "viz_type": "cal_heatmap",
"domain_granularity": "month", "domain_granularity": "month",
@ -1002,9 +991,6 @@ def load_long_lat_data():
tbl = obj tbl = obj
slice_data = { slice_data = {
"datasource_id": "7",
"datasource_name": "long_lat",
"datasource_type": "table",
"granularity": "day", "granularity": "day",
"since": "2014-01-01", "since": "2014-01-01",
"until": "now", "until": "now",
@ -1084,10 +1070,8 @@ def load_multiformat_time_series_data():
print("Creating some slices") print("Creating some slices")
for i, col in enumerate(tbl.columns): for i, col in enumerate(tbl.columns):
slice_data = { slice_data = {
"metric": 'count',
"granularity_sqla": col.column_name, "granularity_sqla": col.column_name,
"datasource_id": "8",
"datasource_name": "multiformat_time_series",
"datasource_type": "table",
"granularity": "day", "granularity": "day",
"row_limit": config.get("ROW_LIMIT"), "row_limit": config.get("ROW_LIMIT"),
"since": "1 year ago", "since": "1 year ago",

File diff suppressed because it is too large Load Diff

79
superset/legacy.py Normal file
View File

@ -0,0 +1,79 @@
"""Code related with dealing with legacy / change management"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from superset import frontend_config
import re
FORM_DATA_KEY_WHITELIST = list(frontend_config.get('fields').keys()) + ['slice_id']
def cast_filter_data(form_data):
"""Used by cast_form_data to parse the filters"""
flts = []
having_flts = []
fd = form_data
filter_pattern = re.compile(r'''((?:[^,"']|"[^"]*"|'[^']*')+)''')
for i in range(0, 10):
for prefix in ['flt', 'having']:
col_str = '{}_col_{}'.format(prefix, i)
op_str = '{}_op_{}'.format(prefix, i)
val_str = '{}_eq_{}'.format(prefix, i)
if col_str in fd and op_str in fd and val_str in fd \
and len(fd[val_str]) > 0:
f = {}
f['col'] = fd[col_str]
f['op'] = fd[op_str]
if prefix == 'flt':
# transfer old strings in filter value to list
splitted = filter_pattern.split(fd[val_str])[1::2]
values = [types.replace("'", '').strip() for types in splitted]
f['val'] = values
flts.append(f)
if prefix == 'having':
f['val'] = fd[val_str]
having_flts.append(f)
if col_str in fd:
del fd[col_str]
if op_str in fd:
del fd[op_str]
if val_str in fd:
del fd[val_str]
fd['filters'] = flts
fd['having_filters'] = having_flts
return fd
def cast_form_data(form_data):
"""Translates old to new form_data"""
d = {}
fields = frontend_config.get('fields', {})
for k, v in form_data.items():
field_config = fields.get(k, {})
ft = field_config.get('type')
if ft == 'CheckboxField':
# bug in some urls with dups on bools
if isinstance(v, list):
v = 'y' in v
else:
v = True if v in ('true', 'y') or v is True else False
elif v and ft == 'TextField' and field_config.get('isInt'):
v = int(v) if v != '' else None
elif v and ft == 'TextField' and field_config.get('isFloat'):
v = float(v) if v != '' else None
elif v and ft == 'SelectField':
if field_config.get('multi') and not isinstance(v, list):
v = [v]
if d.get('slice_id'):
d['slice_id'] = int(d['slice_id'])
d[k] = v
if 'filters' not in d:
d = cast_filter_data(d)
for k in d.keys():
if k not in FORM_DATA_KEY_WHITELIST:
del d[k]
return d

View File

@ -0,0 +1,71 @@
"""rewriting url from shortner with new format
Revision ID: a99f2f7c195a
Revises: 53fc3de270ae
Create Date: 2017-02-08 14:16:34.948793
"""
# revision identifiers, used by Alembic.
revision = 'a99f2f7c195a'
down_revision = 'db0c65b146bd'
from alembic import op
import json
import sqlalchemy as sa
from superset import db
from superset.legacy import cast_form_data
from sqlalchemy.ext.declarative import declarative_base
from future.standard_library import install_aliases
install_aliases()
from urllib import parse
Base = declarative_base()
def parse_querystring(qs):
d = {}
for k, v in parse.parse_qsl(qs):
if not k in d:
d[k] = v
else:
if isinstance(d[k], list):
d[k].append(v)
else:
d[k] = [d[k], v]
return d
class Url(Base):
"""Used for the short url feature"""
__tablename__ = 'url'
id = sa.Column(sa.Integer, primary_key=True)
url = sa.Column(sa.Text)
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind)
urls = session.query(Url).all()
urls_len = len(urls)
for i, url in enumerate(urls):
if (
'?form_data' not in url.url and
'?' in url.url and
'dbid' not in url.url and
url.url.startswith('//superset/explore')):
d = parse_querystring(url.url.split('?')[1])
split = url.url.split('/')
d['datasource'] = split[5] + '__' + split[4]
d = cast_form_data(d)
newurl = '/'.join(split[:-1]) + '/?form_data=' + parse.quote_plus(json.dumps(d))
url.url = newurl
session.merge(url)
session.commit()
print('Updating url ({}/{})'.format(i, urls_len))
session.close()
def downgrade():
pass

View File

@ -0,0 +1,22 @@
"""empty message
Revision ID: d6db5a5cdb5d
Revises: ('a99f2f7c195a', 'bcf3126872fc')
Create Date: 2017-02-10 17:58:20.149960
"""
# revision identifiers, used by Alembic.
revision = 'd6db5a5cdb5d'
down_revision = ('a99f2f7c195a', 'bcf3126872fc')
from alembic import op
import sqlalchemy as sa
def upgrade():
pass
def downgrade():
pass

View File

@ -0,0 +1,54 @@
"""update_slice_model_json
Revision ID: db0c65b146bd
Revises: f18570e03440
Create Date: 2017-01-24 12:31:06.541746
"""
# revision identifiers, used by Alembic.
revision = 'db0c65b146bd'
down_revision = 'f18570e03440'
from alembic import op
import json
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text
from superset import db
from superset.legacy import cast_form_data
Base = declarative_base()
class Slice(Base):
"""Declarative class to do query in upgrade"""
__tablename__ = 'slices'
id = Column(Integer, primary_key=True)
datasource_type = Column(String(200))
slice_name = Column(String(200))
params = Column(Text)
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind)
slices = session.query(Slice).all()
slice_len = len(slices)
for i, slc in enumerate(slices):
try:
d = json.loads(slc.params or '{}')
d = cast_form_data(d)
slc.params = json.dumps(d, indent=2, sort_keys=True)
session.merge(slc)
session.commit()
print('Upgraded ({}/{}): {}'.format(i, slice_len, slc.slice_name))
except Exception as e:
print(slc.slice_name + ' error: ' + str(e))
session.close()
def downgrade():
pass

View File

@ -4,7 +4,6 @@ from __future__ import division
from __future__ import print_function from __future__ import print_function
from __future__ import unicode_literals from __future__ import unicode_literals
import ast
from collections import OrderedDict from collections import OrderedDict
import functools import functools
import json import json
@ -13,6 +12,9 @@ import numpy
import pickle import pickle
import re import re
import textwrap import textwrap
from future.standard_library import install_aliases
install_aliases()
from urllib import parse
from copy import deepcopy, copy from copy import deepcopy, copy
from datetime import timedelta, datetime, date from datetime import timedelta, datetime, date
@ -24,7 +26,7 @@ from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import subqueryload from sqlalchemy.orm import subqueryload
import sqlparse import sqlparse
from dateutil.parser import parse from dateutil.parser import parse as dparse
from flask import escape, g, Markup, request from flask import escape, g, Markup, request
from flask_appbuilder import Model from flask_appbuilder import Model
@ -52,11 +54,10 @@ from sqlalchemy.sql import table, literal_column, text, column
from sqlalchemy.sql.expression import ColumnClause, TextAsFrom from sqlalchemy.sql.expression import ColumnClause, TextAsFrom
from sqlalchemy_utils import EncryptedType from sqlalchemy_utils import EncryptedType
from werkzeug.datastructures import ImmutableMultiDict
from superset import ( from superset import (
app, db, db_engine_specs, get_session, utils, sm, import_util app, db, db_engine_specs, get_session, utils, sm, import_util,
) )
from superset.legacy import cast_form_data
from superset.source_registry import SourceRegistry from superset.source_registry import SourceRegistry
from superset.viz import viz_types from superset.viz import viz_types
from superset.jinja_context import get_template_processor from superset.jinja_context import get_template_processor
@ -309,34 +310,37 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
d['error'] = str(e) d['error'] = str(e)
d['slice_id'] = self.id return {
d['slice_name'] = self.slice_name 'datasource': self.datasource_name,
d['description'] = self.description 'description': self.description,
d['slice_url'] = self.slice_url 'description_markeddown': self.description_markeddown,
d['edit_url'] = self.edit_url 'edit_url': self.edit_url,
d['description_markeddown'] = self.description_markeddown 'form_data': self.form_data,
return d 'slice_id': self.id,
'slice_name': self.slice_name,
'slice_url': self.slice_url,
}
@property @property
def json_data(self): def json_data(self):
return json.dumps(self.data) return json.dumps(self.data)
@property
def form_data(self):
form_data = json.loads(self.params)
form_data['slice_id'] = self.id
form_data['viz_type'] = self.viz_type
form_data['datasource'] = (
str(self.datasource_id) + '__' + self.datasource_type)
return form_data
@property @property
def slice_url(self): def slice_url(self):
"""Defines the url to access the slice""" """Defines the url to access the slice"""
try: return (
slice_params = json.loads(self.params)
except Exception as e:
logging.exception(e)
slice_params = {}
slice_params['slice_id'] = self.id
slice_params['json'] = "false"
slice_params['slice_name'] = self.slice_name
from werkzeug.urls import Href
href = Href(
"/superset/explore/{obj.datasource_type}/" "/superset/explore/{obj.datasource_type}/"
"{obj.datasource_id}/".format(obj=self)) "{obj.datasource_id}/?form_data={params}".format(
return href(slice_params) obj=self, params=parse.quote(json.dumps(self.form_data))))
@property @property
def slice_id_url(self): def slice_id_url(self):
@ -364,21 +368,15 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
url_params_multidict or self.params. url_params_multidict or self.params.
:rtype: :py:class:viz.BaseViz :rtype: :py:class:viz.BaseViz
""" """
slice_params = json.loads(self.params) # {} slice_params = json.loads(self.params)
slice_params['slice_id'] = self.id slice_params['slice_id'] = self.id
slice_params['json'] = "false" slice_params['json'] = "false"
slice_params['slice_name'] = self.slice_name slice_params['slice_name'] = self.slice_name
slice_params['viz_type'] = self.viz_type if self.viz_type else "table" slice_params['viz_type'] = self.viz_type if self.viz_type else "table"
if url_params_multidict:
slice_params.update(url_params_multidict)
to_del = [k for k in slice_params if k not in url_params_multidict]
for k in to_del:
del slice_params[k]
immutable_slice_params = ImmutableMultiDict(slice_params) return viz_types[slice_params.get('viz_type')](
return viz_types[immutable_slice_params.get('viz_type')](
self.datasource, self.datasource,
form_data=immutable_slice_params, form_data=slice_params,
slice_=self slice_=self
) )
@ -651,10 +649,13 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin):
}) })
class Queryable(object): class Datasource(object):
"""A common interface to objects that are queryable (tables and datasources)""" """A common interface to objects that are queryable (tables and datasources)"""
# Used to do code highlighting when displaying the query in the UI
query_language = None
@property @property
def column_names(self): def column_names(self):
return sorted([c.column_name for c in self.columns]) return sorted([c.column_name for c in self.columns])
@ -686,33 +687,40 @@ class Queryable(object):
else: else:
return "/superset/explore/{obj.type}/{obj.id}/".format(obj=self) return "/superset/explore/{obj.type}/{obj.id}/".format(obj=self)
@property
def column_formats(self):
return {
m.metric_name: m.d3format
for m in self.metrics
if m.d3format
}
@property @property
def data(self): def data(self):
"""data representation of the datasource sent to the frontend""" """data representation of the datasource sent to the frontend"""
gb_cols = [(col, col) for col in self.groupby_column_names]
all_cols = [(c, c) for c in self.column_names]
filter_cols = [(c, c) for c in self.filterable_column_names]
order_by_choices = [] order_by_choices = []
for s in sorted(self.column_names): for s in sorted(self.column_names):
order_by_choices.append((json.dumps([s, True]), s + ' [asc]')) order_by_choices.append((json.dumps([s, True]), s + ' [asc]'))
order_by_choices.append((json.dumps([s, False]), s + ' [desc]')) order_by_choices.append((json.dumps([s, False]), s + ' [desc]'))
d = { d = {
'id': self.id, 'all_cols': utils.choicify(self.column_names),
'type': self.type, 'column_formats': self.column_formats,
'name': self.name, 'edit_url' : self.url,
'metrics_combo': self.metrics_combo,
'order_by_choices': order_by_choices,
'gb_cols': gb_cols,
'all_cols': all_cols,
'filterable_cols': filter_cols,
'filter_select': self.filter_select_enabled, 'filter_select': self.filter_select_enabled,
'filterable_cols': utils.choicify(self.filterable_column_names),
'gb_cols': utils.choicify(self.groupby_column_names),
'id': self.id,
'metrics_combo': self.metrics_combo,
'name': self.name,
'order_by_choices': order_by_choices,
'type': self.type,
} }
if self.type == 'table': if self.type == 'table':
grains = self.database.grains() or [] grains = self.database.grains() or []
if grains: if grains:
grains = [(g.name, g.name) for g in grains] grains = [(g.name, g.name) for g in grains]
d['granularity_sqla'] = [(c, c) for c in self.dttm_cols] d['granularity_sqla'] = utils.choicify(self.dttm_cols)
d['time_grain_sqla'] = grains d['time_grain_sqla'] = grains
return d return d
@ -1094,11 +1102,12 @@ class SqlMetric(Model, AuditMixinNullable, ImportMixin):
return import_util.import_simple_obj(db.session, i_metric, lookup_obj) return import_util.import_simple_obj(db.session, i_metric, lookup_obj)
class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin): class SqlaTable(Model, Datasource, AuditMixinNullable, ImportMixin):
"""An ORM object for SqlAlchemy table references""" """An ORM object for SqlAlchemy table references"""
type = "table" type = "table"
query_language = 'sql'
__tablename__ = 'tables' __tablename__ = 'tables'
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
@ -1172,7 +1181,7 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
@property @property
def dttm_cols(self): def dttm_cols(self):
l = [c.column_name for c in self.columns if c.is_dttm] l = [c.column_name for c in self.columns if c.is_dttm]
if self.main_dttm_col not in l: if self.main_dttm_col and self.main_dttm_col not in l:
l.append(self.main_dttm_col) l.append(self.main_dttm_col)
return l return l
@ -1261,8 +1270,9 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
con=engine con=engine
) )
def query( # sqla def get_query_str( # sqla
self, groupby, metrics, self, engine, qry_start_dttm,
groupby, metrics,
granularity, granularity,
from_dttm, to_dttm, from_dttm, to_dttm,
filter=None, # noqa filter=None, # noqa
@ -1285,7 +1295,6 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
cols = {col.column_name: col for col in self.columns} cols = {col.column_name: col for col in self.columns}
metrics_dict = {m.metric_name: m for m in self.metrics} metrics_dict = {m.metric_name: m for m in self.metrics}
qry_start_dttm = datetime.now()
if not granularity and is_timeseries: if not granularity and is_timeseries:
raise Exception(_( raise Exception(_(
@ -1374,20 +1383,16 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
where_clause_and = [] where_clause_and = []
having_clause_and = [] having_clause_and = []
for col, op, eq in filter: for flt in filter:
if not all([flt.get(s) for s in ['col', 'op', 'val']]):
continue
col = flt['col']
op = flt['op']
eq = ','.join(flt['val'])
col_obj = cols[col] col_obj = cols[col]
if op in ('in', 'not in'): if op in ('in', 'not in'):
split = FilterPattern.split(eq)[1::2] splitted = FilterPattern.split(eq)[1::2]
values = [types.strip() for types in split] values = [types.strip("'").strip('"') for types in splitted]
# attempt to get the values type if they are not in quotes
if not col_obj.is_string:
try:
values = [ast.literal_eval(v) for v in values]
except Exception as e:
logging.info(utils.error_msg_from_exception(e))
values = [v.replace("'", '').strip() for v in values]
else:
values = [v.replace("'", '').strip() for v in values]
cond = col_obj.sqla_col.in_(values) cond = col_obj.sqla_col.in_(values)
if op == 'not in': if op == 'not in':
cond = ~cond cond = ~cond
@ -1443,12 +1448,18 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
qry = qry.select_from(tbl) qry = qry.select_from(tbl)
engine = self.database.get_sqla_engine()
sql = "{}".format( sql = "{}".format(
qry.compile( qry.compile(
engine, compile_kwargs={"literal_binds": True},), engine, compile_kwargs={"literal_binds": True},),
) )
logging.info(sql)
sql = sqlparse.format(sql, reindent=True) sql = sqlparse.format(sql, reindent=True)
return sql
def query(self, query_obj):
qry_start_dttm = datetime.now()
engine = self.database.get_sqla_engine()
sql = self.get_query_str(engine, qry_start_dttm, **query_obj)
status = QueryStatus.SUCCESS status = QueryStatus.SUCCESS
error_message = None error_message = None
df = None df = None
@ -1873,11 +1884,12 @@ class DruidMetric(Model, AuditMixinNullable, ImportMixin):
return import_util.import_simple_obj(db.session, i_metric, lookup_obj) return import_util.import_simple_obj(db.session, i_metric, lookup_obj)
class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin): class DruidDatasource(Model, AuditMixinNullable, Datasource, ImportMixin):
"""ORM object referencing Druid datasources (tables)""" """ORM object referencing Druid datasources (tables)"""
type = "druid" type = "druid"
query_langtage = "json"
baselink = "druiddatasourcemodelview" baselink = "druiddatasourcemodelview"
@ -2045,7 +2057,7 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
if not results: if not results:
return return
max_time = results[0]['result']['maxTime'] max_time = results[0]['result']['maxTime']
max_time = parse(max_time) max_time = dparse(max_time)
# Query segmentMetadata for 7 days back. However, due to a bug, # Query segmentMetadata for 7 days back. However, due to a bug,
# we need to set this interval to more than 1 day ago to exclude # we need to set this interval to more than 1 day ago to exclude
# realtime segments, which triggered a bug (fixed in druid 0.8.2). # realtime segments, which triggered a bug (fixed in druid 0.8.2).
@ -2286,8 +2298,9 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
return df return df
def query( # druid def get_query_str( # druid
self, groupby, metrics, self, client, qry_start_dttm,
groupby, metrics,
granularity, granularity,
from_dttm, to_dttm, from_dttm, to_dttm,
filter=None, # noqa filter=None, # noqa
@ -2299,13 +2312,12 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
orderby=None, orderby=None,
extras=None, # noqa extras=None, # noqa
select=None, # noqa select=None, # noqa
columns=None, ): columns=None, phase=2):
"""Runs a query against Druid and returns a dataframe. """Runs a query against Druid and returns a dataframe.
This query interface is common to SqlAlchemy and Druid This query interface is common to SqlAlchemy and Druid
""" """
# TODO refactor into using a TBD Query object # TODO refactor into using a TBD Query object
qry_start_dttm = datetime.now()
if not is_timeseries: if not is_timeseries:
granularity = 'all' granularity = 'all'
inner_from_dttm = inner_from_dttm or from_dttm inner_from_dttm = inner_from_dttm or from_dttm
@ -2401,7 +2413,6 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
if having_filters: if having_filters:
qry['having'] = having_filters qry['having'] = having_filters
client = self.cluster.get_pydruid_client()
orig_filters = filters orig_filters = filters
if len(groupby) == 0: if len(groupby) == 0:
del qry['dimensions'] del qry['dimensions']
@ -2440,6 +2451,8 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
query_str += json.dumps( query_str += json.dumps(
client.query_builder.last_query.query_dict, indent=2) client.query_builder.last_query.query_dict, indent=2)
query_str += "\n" query_str += "\n"
if phase == 1:
return query_str
query_str += ( query_str += (
"//\nPhase 2 (built based on phase one's results)\n") "//\nPhase 2 (built based on phase one's results)\n")
df = client.export_pandas() df = client.export_pandas()
@ -2479,15 +2492,24 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
client.groupby(**qry) client.groupby(**qry)
query_str += json.dumps( query_str += json.dumps(
client.query_builder.last_query.query_dict, indent=2) client.query_builder.last_query.query_dict, indent=2)
return query_str
def query(self, query_obj):
qry_start_dttm = datetime.now()
client = self.cluster.get_pydruid_client()
query_str = self.get_query_str(client, qry_start_dttm, **query_obj)
df = client.export_pandas() df = client.export_pandas()
if df is None or df.size == 0: if df is None or df.size == 0:
raise Exception(_("No data was returned.")) raise Exception(_("No data was returned."))
df.columns = [ df.columns = [
DTTM_ALIAS if c == 'timestamp' else c for c in df.columns] DTTM_ALIAS if c == 'timestamp' else c for c in df.columns]
is_timeseries = query_obj['is_timeseries'] \
if 'is_timeseries' in query_obj else True
if ( if (
not is_timeseries and not is_timeseries and
granularity == "all" and query_obj['granularity'] == "all" and
DTTM_ALIAS in df.columns): DTTM_ALIAS in df.columns):
del df[DTTM_ALIAS] del df[DTTM_ALIAS]
@ -2495,11 +2517,11 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
cols = [] cols = []
if DTTM_ALIAS in df.columns: if DTTM_ALIAS in df.columns:
cols += [DTTM_ALIAS] cols += [DTTM_ALIAS]
cols += [col for col in groupby if col in df.columns] cols += [col for col in query_obj['groupby'] if col in df.columns]
cols += [col for col in metrics if col in df.columns] cols += [col for col in query_obj['metrics'] if col in df.columns]
df = df[cols] df = df[cols]
time_offset = DruidDatasource.time_offset(granularity) time_offset = DruidDatasource.time_offset(query_obj['granularity'])
def increment_timestamp(ts): def increment_timestamp(ts):
dt = utils.parse_human_datetime(ts).replace( dt = utils.parse_human_datetime(ts).replace(
@ -2516,7 +2538,12 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
@staticmethod @staticmethod
def get_filters(raw_filters): def get_filters(raw_filters):
filters = None filters = None
for col, op, eq in raw_filters: for flt in raw_filters:
if not all(f in flt for f in ['col', 'op', 'val']):
continue
col = flt['col']
op = flt['op']
eq = flt['val']
cond = None cond = None
if op == '==': if op == '==':
cond = Dimension(col) == eq cond = Dimension(col) == eq
@ -2569,7 +2596,12 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
'<=': '>' '<=': '>'
} }
for col, op, eq in raw_filters: for flt in raw_filters:
if not all(f in flt for f in ['col', 'op', 'val']):
continue
col = flt['col']
op = flt['op']
eq = flt['val']
cond = None cond = None
if op in ['==', '>', '<']: if op in ['==', '>', '<']:
cond = self._get_having_obj(col, op, eq) cond = self._get_having_obj(col, op, eq)

View File

@ -1,307 +0,0 @@
{% extends "superset/basic.html" %}
{% block title %}
{% if slice %}
[slice] {{ slice.slice_name }}
{% else %}
[explore] {{ viz.datasource.table_name }}
{% endif %}
{% endblock %}
{% block body %}
{% set datasource = viz.datasource %}
{% set form = viz.form %}
{% macro panofield(fieldname)%}
<div>
{% set field = form.get_field(fieldname)%}
<div>
{{ field.label }}
{% if field.description %}
<i class="fa fa-question-circle-o" data-toggle="tooltip" data-placement="right"
title="{{ field.description }}"></i>
{% endif %}
{{ field(class_=form.field_css_classes(field.name)) }}
</div>
</div>
{% endmacro %}
<div class="datasource container-fluid">
<form id="query" method="GET" style="display: none;">
<div id="form_container" class="row">
<div class="col-md-3">
<div
id="js-query-and-save-btns"
class="query-and-save-btns-container"
data-can-add="{{ can_add }}"
>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<div class="panel-title">
Datasource & Chart Type
</div>
</div>
<div class="panel-body">
<div>
<!-- DATASOURCE -->
<span title="Data Source" data-toggle="tooltip">
<select id="datasource_id" class="select2">
{% for ds in datasources %}
<option url="{{ ds.explore_url }}" {{ "selected" if ds.id == datasource.id }} value="{{ ds.id }}">{{ ds.full_name }}<i class="fa fa-info"></i></option>
{% endfor %}
</select>
</span>
<a href="{{ datasource.url }}" data-toggle="tooltip" title="Edit/configure datasource">
<i class="fa fa-edit m-l-3"></i>
</a>
</div>
<br/>
<!-- CHART TYPE -->
<div title="Visualization Type" data-toggle="tooltip">
{{ form.get_field("viz_type")(class_="select2-with-images") }}
</div>
</div>
</div>
{% for fieldset in form.fieldsets %}
<div class="panel panel-default">
{% if fieldset.label %}
<div class="panel-heading">
<div class="panel-title">
{{ fieldset.label }}
{% if fieldset.description %}
<i class="fa fa-question-circle-o" data-toggle="tooltip"
data-placement="bottom"
title="{{ fieldset.description }}"></i>
{% endif %}
</div>
</div>
{% endif %}
<div class="panel-body">
{% for fieldname in fieldset.fields %}
{% if not fieldname %}
<hr/>
{% elif fieldname is string %}
{{ panofield(fieldname) }}
{% else %}
<div class="row">
<div class="form-group">
{% for name in fieldname %}
<div class="col-xs-{{ (12 / fieldname|length) | int }}">
{% if name %}
{{ panofield(name) }}
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
<div id="filter_panel" class="panel panel-default">
<div class="panel-heading">
<div class="panel-title">
{{ _("Filters") }}
<i class="fa fa-question-circle-o" data-toggle="tooltip"
data-placement="right"
title="{{_("Filters are defined using comma delimited strings as in <US,FR,Other>")}}.
{{_("Leave the value field empty to filter empty strings or nulls")}}.
{{_("For filters with comma in values, wrap them in single quotes,
as in <NY, 'Tahoe, CA', DC>")}}">
</i>
</div>
</div>
<div class="panel-body">
<div id="flt0" style="display: none;">
<span class="">{{ form.flt_col_0(class_="form-control inc") }}</span>
<div class="row">
<span class="col col-sm-4">{{ form.flt_op_0(class_="form-control inc") }}</span>
<span class="col col-sm-6">{{ form.flt_eq_0(class_="form-control inc") }}</span>
<button type="button" class="btn btn-default btn-sm remove" aria-label="Delete filter">
<span class="fa fa-minus" aria-hidden="true"></span>
</button>
</div>
</div>
<div id="filters"></div>
<button type="button" id="plus" class="btn btn-default btn-sm" aria-label="Add a filter">
<span class="fa fa-plus" aria-hidden="true"></span>
<span>{{ _("Add filter") }}</span>
</button>
</div>
</div>
{% if form.having_col_0 %}
<div id="having_panel" class="panel panel-default">
<div class="panel-heading">
<div class="panel-title">
Result Filters ("having" filters)
<i
class="fa fa-info-circle"
data-toggle="tooltip"
data-placement="bottom"
title="{{_("The filters to apply after post-aggregation.")}} {{_("Leave the value field empty to filter empty strings or nulls")}}">
</i>
</div>
</div>
<div class="panel-body">
<div id="having0" style="display: none;">
<span class="">{{ form.having_col_0(class_="form-control inc") }}</span>
<div class="row">
<span class="col col-sm-4">{{ form.having_op_0(class_="form-control inc") }}</span>
<span class="col col-sm-6">{{ form.having_eq_0(class_="form-control inc") }}</span>
<button type="button"
class="btn btn-default btn-sm remove"
aria-label="Delete filter">
<span class="fa fa-minus"
aria-hidden="true"></span>
</button>
</div>
</div>
<div id="filters"></div>
<button type="button" id="plus"
class="btn btn-default btn-sm"
aria-label="Add a filter">
<span class="fa fa-plus" aria-hidden="true"></span>
<span>Add filter</span>
</button>
</div>
</div>
{% endif %}
{{ form.slice_id() }}
{{ form.slice_name() }}
{{ form.collapsed_fieldsets() }}
<input type="hidden" name="action" id="action" value="">
<input type="hidden" name="userid" id="userid" value="{{ userid }}">
<input type="hidden" name="goto_dash" id="goto_dash" value="false">
<input type="hidden" name="datasource_name" value="{{ datasource.name }}">
<input type="hidden" name="datasource_id" value="{{ datasource.id }}">
<input type="hidden" name="datasource_type" value="{{ datasource.type }}">
<input type="hidden" name="previous_viz_type" value="{{ viz.viz_type or "table" }}">
</div>
<div class="col-md-9">
{% block messages %}{% endblock %}
{% include 'appbuilder/flash.html' %}
<div class="panel panel-default">
<div class="panel-heading">
<div class="panel-title">
<div class="clearfix">
<div class="pull-left">
{% include 'superset/partials/_explore_title.html' %}
</div>
<div class="pull-right">
<div class="slice-meta-controls pull-right">
<span id="is_cached" class="label label-default m-r-3" title="{{ _("Force refresh" )}}" data-toggle="tooltip">
cached
</span>
<span
class="label label-warning m-r-3"
id="timer"
title="{{ _("Query timer") }}"
data-toggle="tooltip">
{{ _("0 sec") }}
</span>
<span
id="js-explore-actions"
data-can-download="{{can_download}}"
>
</span>
</div>
</div>
</div>
</div>
</div>
<div class="panel-body">
<div
id="{{ viz.token }}"
class="widget viz slice {{ viz.viz_type }}"
data-slice="{{ viz.json_data }}"
style="height: 700px;">
<img src="{{ url_for("static", filename="assets/images/loading.gif") }}" class="loading" alt="loading">
<div id="{{ viz.token }}_con" class="slice_container" style="height: 100%; width: 100%"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="sourceinfo_modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">{{ _("Datasource Description") }}</h4>
</div>
<div class="modal-body">
{{ datasource.description_markeddown | safe }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ _("Close") }}</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="save_modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">{{ _("Save a Slice") }}</h4>
</div>
<div class="modal-body">
{% if slice %}
<input
type="radio"
name="rdo_save"
value="overwrite"
{{ 'disabled' if not can_edit else '' }}
{{ 'checked' if can_edit else '' }}>
Overwrite slice [{{ slice.slice_name }}] <br><br>
{% endif %}
<input
id="save_as_new"
type="radio"
name="rdo_save"
value="saveas"
{{ 'checked' if not slice or not can_edit else '' }}>
Save as
<input type="text" name="new_slice_name" placeholder="[slice name]"><br>
<hr/>
<input type="radio" name="add_to_dash" checked value="false">Do not add to a dashboard<br><br>
<input id="add_to_dash_existing" type="radio" name="add_to_dash" value="existing">Add slice to existing dashboard
<input type="text" id="save_to_dashboard_id" name="save_to_dashboard_id"><br><br>
<input type="radio" id="add_to_new_dash" name="add_to_dash" value="new">Add to new dashboard
<input type="text" name="new_dashboard_name" placeholder="[dashboard name]"> <br><br>
</div>
<div class="modal-footer">
<button type="button" id="btn_modal_save" class="btn pull-left">
{{ _("Save") }}
</button>
<button type="button" id="btn_modal_save_goto_dash" class="btn btn-primary pull-left gotodash" disabled>
{{ _("Save & go to dashboard") }}
</button>
<button type="button" class="btn btn-default pull-right" data-dismiss="modal">
{{ _("Cancel") }}
</button>
</div>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block tail_js %}
{{ super() }}
{% with filename="explore" %}
{% include "superset/partials/_script_tag.html" %}
{% endwith %}
{% endblock %}

View File

@ -1,33 +0,0 @@
<html>
<head>
<title>{{ viz.token }}</title>
<link rel="stylesheet" type="text/css" href="/static/assets/node_modules/font-awesome/css/font-awesome.min.css" />
<link rel="stylesheet" type="text/css" href="/static/assets/stylesheets/superset.css" />
<link rel="stylesheet" type="text/css" href="/static/appbuilder/css/flags/flags16.css" />
<link rel="icon" type="image/png" href="/static/assets/images/favicon.png">
{% set CSS_THEME = appbuilder.get_app.config.get("CSS_THEME") %}
{% set height = request.args.get("height", 700) %}
{% if CSS_THEME %}
<link rel="stylesheet" type="text/css" href="{{ CSS_THEME }}" />
{% endif %}
</head>
<body>
<div
id="{{ viz.token }}"
class="widget viz slice {{ viz.viz_type }}"
data-slice="{{ viz.json_data }}"
data-standalone="true"
style="height: {{ height }}px;">
<img src="{{ url_for("static", filename="assets/images/loading.gif") }}" class="loading" alt="loading">
<div id="{{ viz.token }}_con" class="slice_container" style="height: 100%; width: 100%"></div>
</div>
</body>
{% with filename="css-theme" %}
{% include "superset/partials/_script_tag.html" %}
{% endwith %}
{% with filename="standalone" %}
{% include "superset/partials/_script_tag.html" %}
{% endwith %}
</html>

View File

@ -529,8 +529,6 @@ def get_email_address_list(address_string):
return address_string return address_string
# Forked from the flask_appbuilder.security.decorators
# TODO(bkyryliuk): contribute it back to FAB
def has_access(f): def has_access(f):
""" """
Use this decorator to enable granular security permissions to your Use this decorator to enable granular security permissions to your
@ -538,6 +536,9 @@ def has_access(f):
associated to users. associated to users.
By default the permission's name is the methods name. By default the permission's name is the methods name.
Forked from the flask_appbuilder.security.decorators
TODO(bkyryliuk): contribute it back to FAB
""" """
if hasattr(f, '_permission_name'): if hasattr(f, '_permission_name'):
permission_str = f._permission_name permission_str = f._permission_name
@ -559,3 +560,8 @@ def has_access(f):
next=request.path)) next=request.path))
f._permission_name = permission_str f._permission_name = permission_str
return functools.update_wrapper(wraps, f) return functools.update_wrapper(wraps, f)
def choicify(values):
"""Takes an iterable and makes an iterable of tuples with it"""
return [(v, v) for v in values]

View File

@ -18,7 +18,7 @@ import functools
import sqlalchemy as sqla import sqlalchemy as sqla
from flask import ( from flask import (
g, request, redirect, flash, Response, render_template, Markup, url_for) g, request, redirect, flash, Response, render_template, Markup)
from flask_appbuilder import ModelView, CompactCRUDMixin, BaseView, expose from flask_appbuilder import ModelView, CompactCRUDMixin, BaseView, expose
from flask_appbuilder.actions import action from flask_appbuilder.actions import action
from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.models.sqla.interface import SQLAInterface
@ -32,7 +32,6 @@ from flask_babel import lazy_gettext as _
from sqlalchemy import create_engine from sqlalchemy import create_engine
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from wtforms.validators import ValidationError
import superset import superset
from superset import ( from superset import (
@ -183,15 +182,17 @@ def get_error_msg():
return error_msg return error_msg
def json_error_response(msg, status=None): def json_error_response(msg, status=None, stacktrace=None):
data = {'error': msg} data = {'error': msg}
if stacktrace:
data['stacktrace'] = stacktrace
status = status if status else 500 status = status if status else 500
return Response( return Response(
json.dumps(data), status=status, mimetype="application/json") json.dumps(data),
status=status, mimetype="application/json")
def json_success(json_msg, status=None): def json_success(json_msg, status=200):
status = status if status else 200
return Response(json_msg, status=status, mimetype="application/json") return Response(json_msg, status=status, mimetype="application/json")
@ -209,6 +210,9 @@ def api(f):
return functools.update_wrapper(wraps, f) return functools.update_wrapper(wraps, f)
def is_owner(obj, user):
""" Check if user is owner of the slice """
return obj and obj.owners and user in obj.owners
def check_ownership(obj, raise_if_false=True): def check_ownership(obj, raise_if_false=True):
"""Meant to be used in `pre_update` hooks on models to enforce ownership """Meant to be used in `pre_update` hooks on models to enforce ownership
@ -355,7 +359,7 @@ def validate_json(form, field): # noqa
json.loads(field.data) json.loads(field.data)
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
raise ValidationError("json isn't valid") raise Exception("json isn't valid")
def generate_download_headers(extension): def generate_download_headers(extension):
@ -1262,12 +1266,25 @@ class Superset(BaseSupersetView):
role = sm.find_role(role_name) role = sm.find_role(role_name)
role.user = existing_users role.user = existing_users
sm.get_session.commit() sm.get_session.commit()
return Response(json.dumps({ return self.json_response({
'role': role_name, 'role': role_name,
'# missing users': len(missing_users), '# missing users': len(missing_users),
'# granted': len(existing_users), '# granted': len(existing_users),
'created_users': created_users, 'created_users': created_users,
}), status=201) }, status=201)
def json_response(self, obj, status=200):
return Response(
json.dumps(obj, default=utils.json_int_dttm_ser),
status=status,
mimetype="application/json")
@has_access_api
@expose("/datasources/")
def datasources(self):
datasources = SourceRegistry.get_all_datasources(db.session)
datasources = [(str(o.id) + '__' + o.type, repr(o)) for o in datasources]
return self.json_response(datasources)
@has_access_api @has_access_api
@expose("/override_role_permissions/", methods=['POST']) @expose("/override_role_permissions/", methods=['POST'])
@ -1317,10 +1334,10 @@ class Superset(BaseSupersetView):
role.permissions.append(view_menu_perm) role.permissions.append(view_menu_perm)
granted_perms.append(view_menu_perm.view_menu.name) granted_perms.append(view_menu_perm.view_menu.name)
db.session.commit() db.session.commit()
return Response(json.dumps({ return self.json_response({
'granted': granted_perms, 'granted': granted_perms,
'requested': list(db_ds_names) 'requested': list(db_ds_names)
}), status=201) }, status=201)
@log_this @log_this
@has_access @has_access
@ -1446,6 +1463,20 @@ class Superset(BaseSupersetView):
session.commit() session.commit()
return redirect('/accessrequestsmodelview/list/') return redirect('/accessrequestsmodelview/list/')
def get_form_data(self):
form_data = request.args.get("form_data")
if not form_data:
form_data = request.form.get("form_data")
if not form_data:
form_data = '{}'
d = json.loads(form_data)
extra_filters = request.args.get("extra_filters")
filters = d.get('filters', [])
if extra_filters:
extra_filters = json.loads(extra_filters)
d['filters'] = filters + extra_filters
return d
def get_viz( def get_viz(
self, self,
slice_id=None, slice_id=None,
@ -1453,21 +1484,38 @@ class Superset(BaseSupersetView):
datasource_type=None, datasource_type=None,
datasource_id=None): datasource_id=None):
if slice_id: if slice_id:
slc = db.session.query(models.Slice).filter_by(id=slice_id).one() slc = (
db.session.query(models.Slice)
.filter_by(id=slice_id)
.one()
)
return slc.get_viz() return slc.get_viz()
else: else:
viz_type = args.get('viz_type', 'table') form_data=self.get_form_data()
viz_type = form_data.get('viz_type', 'table')
datasource = SourceRegistry.get_datasource( datasource = SourceRegistry.get_datasource(
datasource_type, datasource_id, db.session) datasource_type, datasource_id, db.session)
viz_obj = viz.viz_types[viz_type]( viz_obj = viz.viz_types[viz_type](
datasource, request.args if request.args else args) datasource,
form_data=form_data,
)
return viz_obj return viz_obj
@has_access @has_access
@expose("/slice/<slice_id>/") @expose("/slice/<slice_id>/")
def slice(self, slice_id): def slice(self, slice_id):
viz_obj = self.get_viz(slice_id) viz_obj = self.get_viz(slice_id)
return redirect(viz_obj.get_url(**request.args)) endpoint = (
'/superset/explore/{}/{}?form_data={}'
.format(
viz_obj.datasource.type,
viz_obj.datasource.id,
json.dumps(viz_obj.form_data)
)
)
if request.args.get("standalone") == "true":
endpoint += '&standalone=true'
return redirect(endpoint)
@log_this @log_this
@has_access_api @has_access_api
@ -1480,21 +1528,54 @@ class Superset(BaseSupersetView):
args=request.args) args=request.args)
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
return json_error_response(utils.error_msg_from_exception(e)) return json_error_response(
utils.error_msg_from_exception(e),
stacktrace=traceback.format_exc())
if not self.datasource_access(viz_obj.datasource): if not self.datasource_access(viz_obj.datasource):
return json_error_response(DATASOURCE_ACCESS_ERR, status=404) return json_error_response(DATASOURCE_ACCESS_ERR, status=404)
if request.args.get("csv") == "true":
return Response(
viz_obj.get_csv(),
status=200,
headers=generate_download_headers("csv"),
mimetype="application/csv")
if request.args.get("query") == "true":
try:
query_obj = viz_obj.query_obj()
engine = viz_obj.datasource.database.get_sqla_engine() \
if datasource_type == 'table' \
else viz_obj.datasource.cluster.get_pydruid_client()
if datasource_type == 'druid':
# only retrive first phase query for druid
query_obj['phase'] = 1
query = viz_obj.datasource.get_query_str(
engine, datetime.now(), **query_obj)
except Exception as e:
return json_error_response(e)
return Response(
json.dumps({
'query': query,
'language': viz_obj.datasource.query_language,
}),
status=200,
mimetype="application/json")
payload = {} payload = {}
try: try:
payload = viz_obj.get_payload() payload = viz_obj.get_payload()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
return json_error_response(utils.error_msg_from_exception(e)) return json_error_response(utils.error_msg_from_exception(e))
if payload.get('status') == QueryStatus.FAILED:
return json_error_response(viz_obj.json_dumps(payload))
return json_success(viz_obj.json_dumps(payload)) status = 200
if payload.get('status') == QueryStatus.FAILED:
status = 400
return json_success(viz_obj.json_dumps(payload), status=status)
@expose("/import_dashboards", methods=['GET', 'POST']) @expose("/import_dashboards", methods=['GET', 'POST'])
@log_this @log_this
@ -1523,35 +1604,31 @@ class Superset(BaseSupersetView):
@has_access @has_access
@expose("/explore/<datasource_type>/<datasource_id>/") @expose("/explore/<datasource_type>/<datasource_id>/")
def explore(self, datasource_type, datasource_id): def explore(self, datasource_type, datasource_id):
viz_type = request.args.get("viz_type") form_data = self.get_form_data()
slice_id = request.args.get('slice_id')
slc = None datasource_id = int(datasource_id)
viz_type = form_data.get("viz_type")
slice_id = form_data.get('slice_id')
user_id = g.user.get_id() if g.user else None user_id = g.user.get_id() if g.user else None
slc = None
if slice_id: if slice_id:
slc = db.session.query(models.Slice).filter_by(id=slice_id).first() slc = db.session.query(models.Slice).filter_by(id=slice_id).first()
error_redirect = '/slicemodelview/list/' error_redirect = '/slicemodelview/list/'
datasource_class = SourceRegistry.sources[datasource_type] datasource = (
datasources = db.session.query(datasource_class).all() db.session.query(SourceRegistry.sources[datasource_type])
datasources = sorted(datasources, key=lambda ds: ds.full_name) .filter_by(id=datasource_id)
.one()
)
try: if not datasource:
viz_obj = self.get_viz( flash(DATASOURCE_MISSING_ERR, "danger")
datasource_type=datasource_type,
datasource_id=datasource_id,
args=request.args)
except Exception as e:
flash('{}'.format(e), "alert")
return redirect(error_redirect) return redirect(error_redirect)
if not viz_obj.datasource: if not self.datasource_access(datasource):
flash(DATASOURCE_MISSING_ERR, "alert")
return redirect(error_redirect)
if not self.datasource_access(viz_obj.datasource):
flash( flash(
__(get_datasource_access_error_msg(viz_obj.datasource.name)), __(get_datasource_access_error_msg(datasource.name)),
"danger") "danger")
return redirect( return redirect(
'superset/request_access/?' 'superset/request_access/?'
@ -1559,65 +1636,49 @@ class Superset(BaseSupersetView):
'datasource_id={datasource_id}&' 'datasource_id={datasource_id}&'
''.format(**locals())) ''.format(**locals()))
if not viz_type and viz_obj.datasource.default_endpoint: if not viz_type and datasource.default_endpoint:
return redirect(viz_obj.datasource.default_endpoint) return redirect(datasource.default_endpoint)
# slc perms # slc perms
slice_add_perm = self.can_access('can_add', 'SliceModelView') slice_add_perm = self.can_access('can_add', 'SliceModelView')
slice_edit_perm = check_ownership(slc, raise_if_false=False) slice_overwrite_perm = is_owner(slc, g.user)
slice_download_perm = self.can_access('can_download', 'SliceModelView') slice_download_perm = self.can_access('can_download', 'SliceModelView')
# handle save or overwrite # handle save or overwrite
action = request.args.get('action') action = request.args.get('action')
if action in ('saveas', 'overwrite'): if action in ('saveas', 'overwrite'):
return self.save_or_overwrite_slice( return self.save_or_overwrite_slice(
request.args, slc, slice_add_perm, slice_edit_perm) request.args,
slc, slice_add_perm,
slice_overwrite_perm,
datasource_id,
datasource_type)
# find out if user is in explore v2 beta group form_data['datasource'] = str(datasource_id) + '__' + datasource_type
# and set flag `is_in_explore_v2_beta` standalone = request.args.get("standalone") == "true"
is_in_explore_v2_beta = sm.find_role('explore-v2-beta') in get_user_roles() bootstrap_data = {
"can_add": slice_add_perm,
# handle different endpoints "can_download": slice_download_perm,
if request.args.get("csv") == "true": "can_overwrite": slice_overwrite_perm,
payload = viz_obj.get_csv() "datasource": datasource.data,
return Response( # TODO: separate endpoint for fetching datasources
payload, "form_data": form_data,
status=200, "datasource_id": datasource_id,
headers=generate_download_headers("csv"), "datasource_type": datasource_type,
mimetype="application/csv") "slice": slc.data if slc else None,
elif request.args.get("standalone") == "true": "standalone": standalone,
return self.render_template("superset/standalone.html", viz=viz_obj, standalone_mode=True) "user_id": user_id,
elif request.args.get("V2") == "true" or is_in_explore_v2_beta: "forced_height": request.args.get('height'),
# bootstrap data for explore V2 }
bootstrap_data = { table_name = datasource.table_name \
"can_add": slice_add_perm, if datasource_type == 'table' \
"can_download": slice_download_perm, else datasource.datasource_name
"can_edit": slice_edit_perm, return self.render_template(
# TODO: separate endpoint for fetching datasources "superset/explorev2.html",
"datasources": [(d.id, d.full_name) for d in datasources], bootstrap_data=json.dumps(bootstrap_data),
"datasource_id": datasource_id, slice=slc,
"datasource_name": viz_obj.datasource.name, standalone_mode=standalone,
"datasource_type": datasource_type, table_name=table_name)
"user_id": user_id,
"viz": json.loads(viz_obj.json_data),
"filter_select": viz_obj.datasource.filter_select_enabled
}
table_name = viz_obj.datasource.table_name \
if datasource_type == 'table' \
else viz_obj.datasource.datasource_name
return self.render_template(
"superset/explorev2.html",
bootstrap_data=json.dumps(bootstrap_data),
slice=slc,
table_name=table_name)
else:
return self.render_template(
"superset/explore.html",
viz=viz_obj, slice=slc, datasources=datasources,
can_add=slice_add_perm, can_edit=slice_edit_perm,
can_download=slice_download_perm,
userid=g.user.get_id() if g.user else ''
)
@api @api
@has_access_api @has_access_api
@ -1662,44 +1723,28 @@ class Superset(BaseSupersetView):
return json_success(obj.get_values_for_column(column)) return json_success(obj.get_values_for_column(column))
def save_or_overwrite_slice( def save_or_overwrite_slice(
self, args, slc, slice_add_perm, slice_edit_perm): self, args, slc, slice_add_perm, slice_overwrite_perm,
datasource_id, datasource_type):
"""Save or overwrite a slice""" """Save or overwrite a slice"""
slice_name = args.get('slice_name') slice_name = args.get('slice_name')
action = args.get('action') action = args.get('action')
form_data = self.get_form_data()
# TODO use form processing form wtforms
d = args.to_dict(flat=False)
del d['action']
if 'previous_viz_type' in d:
del d['previous_viz_type']
as_list = ('metrics', 'groupby', 'columns', 'all_columns',
'mapbox_label', 'order_by_cols')
for k in d:
v = d.get(k)
if k in as_list and not isinstance(v, list):
d[k] = [v] if v else []
if k not in as_list and isinstance(v, list):
d[k] = v[0]
datasource_type = args.get('datasource_type')
datasource_id = args.get('datasource_id')
if action in ('saveas'): if action in ('saveas'):
if 'slice_id' in d: if 'slice_id' in form_data:
d.pop('slice_id') # don't save old slice_id form_data.pop('slice_id') # don't save old slice_id
slc = models.Slice(owners=[g.user] if g.user else []) slc = models.Slice(owners=[g.user] if g.user else [])
slc.params = json.dumps(d, indent=4, sort_keys=True) slc.params = json.dumps(form_data)
slc.datasource_name = args.get('datasource_name') slc.datasource_name = args.get('datasource_name')
slc.viz_type = args.get('viz_type') slc.viz_type = form_data['viz_type']
slc.datasource_type = datasource_type slc.datasource_type = datasource_type
slc.datasource_id = datasource_id slc.datasource_id = datasource_id
slc.slice_name = slice_name slc.slice_name = slice_name
if action in ('saveas') and slice_add_perm: if action in ('saveas') and slice_add_perm:
self.save_slice(slc) self.save_slice(slc)
elif action == 'overwrite' and slice_edit_perm: elif action == 'overwrite' and slice_overwrite_perm:
self.overwrite_slice(slc) self.overwrite_slice(slc)
# Adding slice to a dashboard if requested # Adding slice to a dashboard if requested
@ -1731,13 +1776,9 @@ class Superset(BaseSupersetView):
db.session.commit() db.session.commit()
if request.args.get('goto_dash') == 'true': if request.args.get('goto_dash') == 'true':
if request.args.get('V2') == 'true': return dash.url
return dash.url
return redirect(dash.url)
else: else:
if request.args.get('V2') == 'true': return slc.slice_url
return slc.slice_url
return redirect(slc.slice_url)
def save_slice(self, slc): def save_slice(self, slc):
session = db.session() session = db.session()
@ -1747,15 +1788,11 @@ class Superset(BaseSupersetView):
flash(msg, "info") flash(msg, "info")
def overwrite_slice(self, slc): def overwrite_slice(self, slc):
can_update = check_ownership(slc, raise_if_false=False) session = db.session()
if not can_update: session.merge(slc)
flash("You cannot overwrite [{}]".format(slc), "danger") session.commit()
else: msg = "Slice [{}] has been overwritten".format(slc.slice_name)
session = db.session() flash(msg, "info")
session.merge(slc)
session.commit()
msg = "Slice [{}] has been overwritten".format(slc.slice_name)
flash(msg, "info")
@api @api
@has_access_api @has_access_api
@ -2603,11 +2640,12 @@ class Superset(BaseSupersetView):
@expose("/fetch_datasource_metadata") @expose("/fetch_datasource_metadata")
@log_this @log_this
def fetch_datasource_metadata(self): def fetch_datasource_metadata(self):
datasource_type = request.args.get('datasource_type') datasource_id, datasource_type = (
request.args.get('datasourceKey').split('__'))
datasource_class = SourceRegistry.sources[datasource_type] datasource_class = SourceRegistry.sources[datasource_type]
datasource = ( datasource = (
db.session.query(datasource_class) db.session.query(datasource_class)
.filter_by(id=request.args.get('datasource_id')) .filter_by(id=int(datasource_id))
.first() .first()
) )

File diff suppressed because it is too large Load Diff

View File

@ -78,14 +78,22 @@ class CoreTests(SupersetTestCase):
self.login(username='admin') self.login(username='admin')
slc = self.get_slice("Girls", db.session) slc = self.get_slice("Girls", db.session)
resp = self.get_resp(slc.viz.json_endpoint) json_endpoint = (
'/superset/explore_json/{}/{}?form_data={}'
.format(slc.datasource_type, slc.datasource_id, json.dumps(slc.viz.form_data))
)
resp = self.get_resp(json_endpoint)
assert '"Jennifer"' in resp assert '"Jennifer"' in resp
def test_slice_csv_endpoint(self): def test_slice_csv_endpoint(self):
self.login(username='admin') self.login(username='admin')
slc = self.get_slice("Girls", db.session) slc = self.get_slice("Girls", db.session)
resp = self.get_resp(slc.viz.csv_endpoint) csv_endpoint = (
'/superset/explore_json/{}/{}?csv=true&form_data={}'
.format(slc.datasource_type, slc.datasource_id, json.dumps(slc.viz.form_data))
)
resp = self.get_resp(csv_endpoint)
assert 'Jennifer,' in resp assert 'Jennifer,' in resp
def test_admin_only_permissions(self): def test_admin_only_permissions(self):
@ -122,24 +130,55 @@ class CoreTests(SupersetTestCase):
db.session.commit() db.session.commit()
copy_name = "Test Sankey Save" copy_name = "Test Sankey Save"
tbl_id = self.table_ids.get('energy_usage') tbl_id = self.table_ids.get('energy_usage')
new_slice_name = "Test Sankey Overwirte"
url = ( url = (
"/superset/explore/table/{}/?viz_type=sankey&groupby=source&" "/superset/explore/table/{}/?slice_name={}&"
"groupby=target&metric=sum__value&row_limit=5000&where=&having=&" "action={}&datasource_name=energy_usage&form_data={}")
"flt_col_0=source&flt_op_0=in&flt_eq_0=&slice_id={}&slice_name={}&"
"collapsed_fieldsets=&action={}&datasource_name=energy_usage&"
"datasource_id=1&datasource_type=table&previous_viz_type=sankey")
# Changing name form_data = {
resp = self.get_resp(url.format(tbl_id, slice_id, copy_name, 'save')) 'viz_type': 'sankey',
assert copy_name in resp 'groupby': 'source',
'groupby': 'target',
'metric': 'sum__value',
'row_limit': 5000,
'slice_id': slice_id,
}
# Changing name and save as a new slice
resp = self.get_resp(
url.format(
tbl_id,
copy_name,
'saveas',
json.dumps(form_data)
)
)
slices = db.session.query(models.Slice) \
.filter_by(slice_name=copy_name).all()
assert len(slices) == 1
new_slice_id = slices[0].id
# Setting the name back to its original name form_data = {
resp = self.get_resp(url.format(tbl_id, slice_id, slice_name, 'save')) 'viz_type': 'sankey',
assert slice_name in resp 'groupby': 'source',
'groupby': 'target',
'metric': 'sum__value',
'row_limit': 5000,
'slice_id': new_slice_id,
}
# Setting the name back to its original name by overwriting new slice
resp = self.get_resp(
url.format(
tbl_id,
new_slice_name,
'overwrite',
json.dumps(form_data)
)
)
slc = db.session.query(models.Slice).filter_by(id=new_slice_id).first()
assert slc.slice_name == new_slice_name
db.session.delete(slc)
# Doing a basic overwrite
assert 'Energy' in self.get_resp(
url.format(tbl_id, slice_id, copy_name, 'overwrite'))
def test_filter_endpoint(self): def test_filter_endpoint(self):
self.login(username='admin') self.login(username='admin')
@ -168,8 +207,6 @@ class CoreTests(SupersetTestCase):
for slc in db.session.query(Slc).all(): for slc in db.session.query(Slc).all():
urls += [ urls += [
(slc.slice_name, 'slice_url', slc.slice_url), (slc.slice_name, 'slice_url', slc.slice_url),
(slc.slice_name, 'json_endpoint', slc.viz.json_endpoint),
(slc.slice_name, 'csv_endpoint', slc.viz.csv_endpoint),
(slc.slice_name, 'slice_id_url', slc.slice_id_url), (slc.slice_name, 'slice_id_url', slc.slice_id_url),
] ]
for name, method, url in urls: for name, method, url in urls:
@ -544,8 +581,7 @@ class CoreTests(SupersetTestCase):
self.login(username='admin') self.login(username='admin')
url = ( url = (
'/superset/fetch_datasource_metadata?' '/superset/fetch_datasource_metadata?'
'datasource_type=table&' + 'datasourceKey=1__table'
'datasource_id=1'
) )
resp = self.get_json_resp(url) resp = self.get_json_resp(url)
keys = [ keys = [

View File

@ -116,30 +116,44 @@ class DruidTests(SupersetTestCase):
resp = self.get_resp('/superset/explore/druid/{}/'.format( resp = self.get_resp('/superset/explore/druid/{}/'.format(
datasource_id)) datasource_id))
self.assertIn("[test_cluster].[test_datasource]", resp) self.assertIn("test_datasource", resp)
form_data = {
'viz_type': 'table',
'granularity': 'one+day',
'druid_time_origin': '',
'since': '7+days+ago',
'until': 'now',
'row_limit': 5000,
'include_search': 'false',
'metrics': ['count'],
'groupby': ['dim1'],
'force': 'true',
}
# One groupby # One groupby
url = ( url = (
'/superset/explore_json/druid/{}/?viz_type=table&granularity=one+day&' '/superset/explore_json/druid/{}/?form_data={}'.format(
'druid_time_origin=&since=7+days+ago&until=now&row_limit=5000&' datasource_id, json.dumps(form_data))
'include_search=false&metrics=count&groupby=dim1&flt_col_0=dim1&' )
'flt_op_0=in&flt_eq_0=&slice_id=&slice_name=&collapsed_fieldsets=&'
'action=&datasource_name=test_datasource&datasource_id={}&'
'datasource_type=druid&previous_viz_type=table&'
'force=true'.format(datasource_id, datasource_id))
resp = self.get_json_resp(url) resp = self.get_json_resp(url)
self.assertEqual("Canada", resp['data']['records'][0]['dim1']) self.assertEqual("Canada", resp['data']['records'][0]['dim1'])
form_data = {
'viz_type': 'table',
'granularity': 'one+day',
'druid_time_origin': '',
'since': '7+days+ago',
'until': 'now',
'row_limit': 5000,
'include_search': 'false',
'metrics': ['count'],
'groupby': ['dim1', 'dim2d'],
'force': 'true',
}
# two groupby # two groupby
url = ( url = (
'/superset/explore_json/druid/{}/?viz_type=table&granularity=one+day&' '/superset/explore_json/druid/{}/?form_data={}'.format(
'druid_time_origin=&since=7+days+ago&until=now&row_limit=5000&' datasource_id, json.dumps(form_data))
'include_search=false&metrics=count&groupby=dim1&' )
'flt_col_0=dim1&groupby=dim2d&'
'flt_op_0=in&flt_eq_0=&slice_id=&slice_name=&collapsed_fieldsets=&'
'action=&datasource_name=test_datasource&datasource_id={}&'
'datasource_type=druid&previous_viz_type=table&'
'force=true'.format(datasource_id, datasource_id))
resp = self.get_json_resp(url) resp = self.get_json_resp(url)
self.assertEqual("Canada", resp['data']['records'][0]['dim1']) self.assertEqual("Canada", resp['data']['records'][0]['dim1'])