mirror of https://github.com/apache/superset.git
[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:
parent
3b023e5eaa
commit
0cc8eff1c3
|
@ -34,5 +34,4 @@ install:
|
|||
- pip install --upgrade pip
|
||||
- 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
|
||||
- npm install
|
||||
script: tox -e $TOX_ENV
|
||||
|
|
|
@ -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.
|
||||
|
||||
```
|
||||
# Copies a conf file from the frontend to the backend
|
||||
npm run sync-backend
|
||||
|
||||
# Compiles the production / optimized js & css
|
||||
npm run prod
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ rm -f .coverage
|
|||
export SUPERSET_CONFIG=tests.superset_test_config
|
||||
set -e
|
||||
superset/bin/superset db upgrade
|
||||
superset/bin/superset db upgrade # running twice on purpose as a test
|
||||
superset/bin/superset version -v
|
||||
python setup.py nosetests
|
||||
coveralls
|
||||
|
|
|
@ -6,6 +6,7 @@ from __future__ import unicode_literals
|
|||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from flask import Flask, redirect
|
||||
|
@ -21,6 +22,10 @@ from superset import utils
|
|||
APP_DIR = os.path.dirname(__file__)
|
||||
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.config.from_object(CONFIG_MODULE)
|
||||
conf = app.config
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,7 +3,7 @@ import cx from 'classnames';
|
|||
import TooltipWrapper from './TooltipWrapper';
|
||||
|
||||
const propTypes = {
|
||||
sliceId: PropTypes.string.isRequired,
|
||||
sliceId: PropTypes.number.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
|
|
@ -13,7 +13,6 @@ import Header from './components/Header';
|
|||
|
||||
require('bootstrap');
|
||||
require('../../stylesheets/dashboard.css');
|
||||
require('../superset-select2.js');
|
||||
|
||||
export function getInitialState(dashboardData, context) {
|
||||
const dashboard = Object.assign({ context }, utils.controllerInterface, dashboardData);
|
||||
|
@ -83,9 +82,6 @@ function initDashboardView(dashboard) {
|
|||
);
|
||||
$('div.grid-container').css('visibility', 'visible');
|
||||
|
||||
$('.select2').select2({
|
||||
dropdownAutoWidth: true,
|
||||
});
|
||||
$('div.widget').click(function (e) {
|
||||
const $this = $(this);
|
||||
const $target = $(e.target);
|
||||
|
@ -165,9 +161,7 @@ export function dashboardContainer(dashboard) {
|
|||
}
|
||||
},
|
||||
effectiveExtraFilters(sliceId) {
|
||||
// Summarized filter, not defined by sliceId
|
||||
// returns k=field, v=array of values
|
||||
const f = {};
|
||||
const f = [];
|
||||
const immuneSlices = this.metadata.filter_immune_slices || [];
|
||||
if (sliceId && immuneSlices.includes(sliceId)) {
|
||||
// The slice is immune to dashboard fiterls
|
||||
|
@ -185,7 +179,11 @@ export function dashboardContainer(dashboard) {
|
|||
for (const filteringSliceId in this.filters) {
|
||||
for (const field in this.filters[filteringSliceId]) {
|
||||
if (!immuneToFields.includes(field)) {
|
||||
f[field] = this.filters[filteringSliceId][field];
|
||||
f.push({
|
||||
col: field,
|
||||
op: 'in',
|
||||
val: this.filters[filteringSliceId][field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@ class GridLayout extends React.Component {
|
|||
id={'slice_' + slice.slice_id}
|
||||
key={slice.slice_id}
|
||||
data-slice-id={slice.slice_id}
|
||||
className={`widget ${slice.viz_name}`}
|
||||
className={`widget ${slice.form_data.viz_type}`}
|
||||
>
|
||||
<SliceCell
|
||||
slice={slice}
|
||||
|
|
|
@ -24,7 +24,7 @@ class Header extends React.PureComponent {
|
|||
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
|
||||
</h1>
|
||||
</div>
|
||||
<div className="pull-right">
|
||||
<div className="pull-right" style={{ marginTop: '35px' }}>
|
||||
{!this.props.dashboard.context.standalone_mode &&
|
||||
<Controls dashboard={dashboard} />
|
||||
}
|
||||
|
|
|
@ -67,13 +67,13 @@ function SliceCell({ expandedSlices, removeSlice, slice }) {
|
|||
</div>
|
||||
<div className="row chart-container">
|
||||
<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
|
||||
src="/static/assets/images/loading.gif"
|
||||
className="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>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
|
@ -13,42 +13,88 @@ export function setDatasource(datasource) {
|
|||
return { type: SET_DATASOURCE, datasource };
|
||||
}
|
||||
|
||||
export const FETCH_STARTED = 'FETCH_STARTED';
|
||||
export function fetchStarted() {
|
||||
return { type: FETCH_STARTED };
|
||||
export const SET_DATASOURCES = 'SET_DATASOURCES';
|
||||
export function setDatasources(datasources) {
|
||||
return { type: SET_DATASOURCES, datasources };
|
||||
}
|
||||
|
||||
export const FETCH_SUCCEEDED = 'FETCH_SUCCEEDED';
|
||||
export function fetchSucceeded() {
|
||||
return { type: FETCH_SUCCEEDED };
|
||||
export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
|
||||
export function fetchDatasourceStarted() {
|
||||
return { type: FETCH_DATASOURCE_STARTED };
|
||||
}
|
||||
|
||||
export const FETCH_FAILED = 'FETCH_FAILED';
|
||||
export function fetchFailed(error) {
|
||||
return { type: FETCH_FAILED, error };
|
||||
export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED';
|
||||
export function fetchDatasourceSucceeded() {
|
||||
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) {
|
||||
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) {
|
||||
const params = [`datasource_id=${datasourceId}`, `datasource_type=${datasourceType}`];
|
||||
const url = '/superset/fetch_datasource_metadata?' + params.join('&');
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url,
|
||||
success: (data) => {
|
||||
dispatch(setDatasource(data));
|
||||
dispatch(fetchSucceeded());
|
||||
},
|
||||
error(error) {
|
||||
dispatch(fetchFailed(error.responseJSON.error));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
dispatch(fetchFailed('Please select a datasource'));
|
||||
}
|
||||
export function fetchDatasources() {
|
||||
return function (dispatch) {
|
||||
dispatch(fetchDatasourcesStarted());
|
||||
const url = '/superset/datasources/';
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url,
|
||||
success: (data) => {
|
||||
dispatch(setDatasources(data));
|
||||
dispatch(fetchDatasourcesSucceeded());
|
||||
},
|
||||
error(error) {
|
||||
dispatch(fetchDatasourcesFailed(error.responseJSON.error));
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -85,8 +131,8 @@ export function setFieldValue(fieldName, value, validationErrors) {
|
|||
}
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted() {
|
||||
return { type: CHART_UPDATE_STARTED };
|
||||
export function chartUpdateStarted(queryRequest) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
|
@ -94,6 +140,14 @@ export function chartUpdateSucceeded(queryResponse) {
|
|||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(queryRequest) {
|
||||
if (queryRequest) {
|
||||
queryRequest.abort();
|
||||
}
|
||||
return { type: CHART_UPDATE_STOPPED };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse };
|
||||
|
@ -126,7 +180,7 @@ export function fetchDashboardsSucceeded(choices) {
|
|||
|
||||
export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED';
|
||||
export function fetchDashboardsFailed(userId) {
|
||||
return { type: FETCH_FAILED, userId };
|
||||
return { type: FETCH_DASHBOARDS_FAILED, userId };
|
||||
}
|
||||
|
||||
export function fetchDashboards(userId) {
|
||||
|
@ -177,12 +231,19 @@ export function updateChartStatus(status) {
|
|||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, datasourceType) {
|
||||
return function (dispatch) {
|
||||
dispatch(updateChartStatus('loading'));
|
||||
const url = getExploreUrl(formData, datasourceType, 'json');
|
||||
$.getJSON(url, function (queryResponse) {
|
||||
const queryRequest = $.getJSON(url, function (queryResponse) {
|
||||
dispatch(chartUpdateSucceeded(queryResponse));
|
||||
}).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 };
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import $ from 'jquery';
|
||||
import React, { PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Panel, Alert } from 'react-bootstrap';
|
||||
import { Panel, Alert, Collapse } from 'react-bootstrap';
|
||||
import visMap from '../../../visualizations/main';
|
||||
import { d3format } from '../../modules/utils';
|
||||
import ExploreActionButtons from '../../explore/components/ExploreActionButtons';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromFields } from '../stores/store';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
|
@ -17,20 +19,20 @@ const CHART_STATUS_MAP = {
|
|||
|
||||
const propTypes = {
|
||||
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,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number.isRequired,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
viz_type: PropTypes.string.isRequired,
|
||||
formData: PropTypes.object,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
};
|
||||
|
||||
class ChartContainer extends React.PureComponent {
|
||||
|
@ -38,14 +40,16 @@ class ChartContainer extends React.PureComponent {
|
|||
super(props);
|
||||
this.state = {
|
||||
selector: `#${props.containerId}`,
|
||||
showStackTrace: false,
|
||||
};
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
this.props.actions.renderTriggered();
|
||||
const mockSlice = this.getMockedSliceObject();
|
||||
this.setState({ mockSlice });
|
||||
try {
|
||||
visMap[this.props.viz_type](mockSlice, this.props.queryResponse);
|
||||
this.setState({ mockSlice });
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e);
|
||||
}
|
||||
|
@ -53,8 +57,13 @@ class ChartContainer extends React.PureComponent {
|
|||
|
||||
componentDidUpdate(prevProps) {
|
||||
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();
|
||||
}
|
||||
|
@ -62,10 +71,15 @@ class ChartContainer extends React.PureComponent {
|
|||
|
||||
getMockedSliceObject() {
|
||||
const props = this.props;
|
||||
const getHeight = () => {
|
||||
const headerHeight = this.props.standalone ? 0 : 100;
|
||||
return parseInt(props.height, 10) - headerHeight;
|
||||
};
|
||||
return {
|
||||
viewSqlQuery: props.query,
|
||||
viewSqlQuery: this.props.queryResponse.query,
|
||||
containerId: props.containerId,
|
||||
selector: this.state.selector,
|
||||
formData: this.props.formData,
|
||||
container: {
|
||||
html: (data) => {
|
||||
// this should be a callback to clear the contents of the slice container
|
||||
|
@ -77,7 +91,7 @@ class ChartContainer extends React.PureComponent {
|
|||
// should call callback to adjust height of chart
|
||||
$(this.state.selector).css(dim, size);
|
||||
},
|
||||
height: () => parseInt(props.height, 10) - 100,
|
||||
height: getHeight,
|
||||
show: () => { },
|
||||
get: (n) => ($(this.state.selector).get(n)),
|
||||
find: (classname) => ($(this.state.selector).find(classname)),
|
||||
|
@ -85,7 +99,7 @@ class ChartContainer extends React.PureComponent {
|
|||
|
||||
width: () => this.chartContainerRef.getBoundingClientRect().width,
|
||||
|
||||
height: () => parseInt(props.height, 10) - 100,
|
||||
height: getHeight,
|
||||
|
||||
setFilter: () => {
|
||||
// set filter according to data in store
|
||||
|
@ -111,9 +125,10 @@ class ChartContainer extends React.PureComponent {
|
|||
},
|
||||
|
||||
data: {
|
||||
csv_endpoint: props.queryResponse.csv_endpoint,
|
||||
json_endpoint: props.queryResponse.json_endpoint,
|
||||
standalone_endpoint: props.queryResponse.standalone_endpoint,
|
||||
csv_endpoint: getExploreUrl(this.props.formData, this.props.datasource_type, 'csv'),
|
||||
json_endpoint: getExploreUrl(this.props.formData, this.props.datasource_type, 'json'),
|
||||
standalone_endpoint: getExploreUrl(
|
||||
this.props.formData, this.props.datasource_type, 'standalone'),
|
||||
},
|
||||
|
||||
};
|
||||
|
@ -125,26 +140,45 @@ class ChartContainer extends React.PureComponent {
|
|||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice_name) {
|
||||
title = this.props.slice_name;
|
||||
if (this.props.slice) {
|
||||
title = this.props.slice.slice_name;
|
||||
} else {
|
||||
title = `[${this.props.table_name}] - untitled`;
|
||||
}
|
||||
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() {
|
||||
if (this.props.alert) {
|
||||
return (
|
||||
<Alert bsStyle="warning">
|
||||
{this.props.alert}
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
return this.renderAlert();
|
||||
}
|
||||
const loading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
|
@ -170,6 +204,9 @@ class ChartContainer extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
return this.renderChart();
|
||||
}
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<Panel
|
||||
|
@ -181,10 +218,10 @@ class ChartContainer extends React.PureComponent {
|
|||
>
|
||||
{this.renderChartTitle()}
|
||||
|
||||
{this.props.slice_id &&
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
<FaveStar
|
||||
sliceId={this.props.slice_id}
|
||||
sliceId={this.props.slice.slice_id}
|
||||
actions={this.props.actions}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
|
@ -195,7 +232,7 @@ class ChartContainer extends React.PureComponent {
|
|||
>
|
||||
<a
|
||||
className="edit-desc-icon"
|
||||
href={`/slicemodelview/edit/${this.props.slice_id}`}
|
||||
href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</a>
|
||||
|
@ -208,16 +245,15 @@ class ChartContainer extends React.PureComponent {
|
|||
startTime={this.props.chartUpdateStartTime}
|
||||
endTime={this.props.chartUpdateEndTime}
|
||||
isRunning={this.props.chartStatus === 'loading'}
|
||||
state={CHART_STATUS_MAP[this.props.chartStatus]}
|
||||
status={CHART_STATUS_MAP[this.props.chartStatus]}
|
||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||
/>
|
||||
{this.state.mockSlice &&
|
||||
<ExploreActionButtons
|
||||
slice={this.state.mockSlice}
|
||||
canDownload={this.props.can_download}
|
||||
query={this.props.queryResponse.query}
|
||||
/>
|
||||
}
|
||||
<ExploreActionButtons
|
||||
slice={this.state.mockSlice}
|
||||
canDownload={this.props.can_download}
|
||||
queryEndpoint={getExploreUrl(
|
||||
this.props.latestQueryFormData, this.props.datasource_type, 'query')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -232,21 +268,24 @@ class ChartContainer extends React.PureComponent {
|
|||
ChartContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const formData = getFormDataFromFields(state.fields);
|
||||
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,
|
||||
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,
|
||||
slice: state.slice,
|
||||
standalone: state.standalone,
|
||||
table_name: formData.datasource_name,
|
||||
viz_type: formData.viz_type,
|
||||
triggerRender: state.triggerRender,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -6,41 +6,72 @@ const propTypes = {
|
|||
label: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
validationErrors: PropTypes.array,
|
||||
renderTrigger: PropTypes.bool,
|
||||
rightNode: PropTypes.node,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
description: null,
|
||||
validationErrors: [],
|
||||
renderTrigger: false,
|
||||
};
|
||||
|
||||
export default function ControlHeader({ label, description, validationErrors }) {
|
||||
export default function ControlHeader({
|
||||
label, description, validationErrors, renderTrigger, rightNode }) {
|
||||
const hasError = (validationErrors.length > 0);
|
||||
return (
|
||||
<ControlLabel>
|
||||
{hasError ?
|
||||
<strong className="text-danger">{label}</strong> :
|
||||
<span>{label}</span>
|
||||
}
|
||||
{' '}
|
||||
{(validationErrors.length > 0) &&
|
||||
<span>
|
||||
<OverlayTrigger
|
||||
placement="right"
|
||||
overlay={
|
||||
<Tooltip id={'error-tooltip'}>
|
||||
{validationErrors.join(' ')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<i className="fa fa-exclamation-circle text-danger" />
|
||||
</OverlayTrigger>
|
||||
<div>
|
||||
<div className="pull-left">
|
||||
<ControlLabel>
|
||||
{hasError ?
|
||||
<strong className="text-danger">{label}</strong> :
|
||||
<span>{label}</span>
|
||||
}
|
||||
{' '}
|
||||
</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 &&
|
||||
<InfoTooltipWithTrigger label={label} tooltip={description} />
|
||||
}
|
||||
</ControlLabel>
|
||||
<div className="clearfix" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,11 @@ import { bindActionCreators } from 'redux';
|
|||
import * as actions from '../actions/exploreActions';
|
||||
import { connect } from 'react-redux';
|
||||
import { Panel, Alert } from 'react-bootstrap';
|
||||
import visTypes, { sectionsToRender } from '../stores/visTypes';
|
||||
import { sectionsToRender } from '../stores/visTypes';
|
||||
import ControlPanelSection from './ControlPanelSection';
|
||||
import FieldSetRow from './FieldSetRow';
|
||||
import FieldSet from './FieldSet';
|
||||
import fields from '../stores/fields';
|
||||
|
||||
const propTypes = {
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
|
@ -23,44 +24,19 @@ const propTypes = {
|
|||
class ControlPanelsContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fieldOverrides = this.fieldOverrides.bind(this);
|
||||
this.getFieldData = this.getFieldData.bind(this);
|
||||
this.removeAlert = this.removeAlert.bind(this);
|
||||
this.getFieldData = this.getFieldData.bind(this);
|
||||
}
|
||||
componentWillMount() {
|
||||
const datasource_id = this.props.form_data.datasource;
|
||||
const datasource_type = this.props.datasource_type;
|
||||
if (datasource_id) {
|
||||
this.props.actions.fetchDatasourceMetadata(datasource_id, datasource_type);
|
||||
getFieldData(fieldName) {
|
||||
const mapF = fields[fieldName].mapStateToProps;
|
||||
if (mapF) {
|
||||
return Object.assign({}, this.props.fields[fieldName], mapF(this.props.exploreState));
|
||||
}
|
||||
}
|
||||
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;
|
||||
return this.props.fields[fieldName];
|
||||
}
|
||||
sectionsToRender() {
|
||||
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() {
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
}
|
||||
|
@ -78,7 +54,7 @@ class ControlPanelsContainer extends React.Component {
|
|||
/>
|
||||
</Alert>
|
||||
}
|
||||
{!this.props.isDatasourceMetaLoading && this.sectionsToRender().map((section) => (
|
||||
{this.sectionsToRender().map((section) => (
|
||||
<ControlPanelSection
|
||||
key={section.label}
|
||||
label={section.label}
|
||||
|
@ -94,7 +70,6 @@ class ControlPanelsContainer extends React.Component {
|
|||
value={this.props.form_data[fieldName]}
|
||||
validationErrors={this.props.fields[fieldName].validationErrors}
|
||||
actions={this.props.actions}
|
||||
prefix={section.prefix}
|
||||
{...this.getFieldData(fieldName)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,24 +1,27 @@
|
|||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import React, { PropTypes } from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import * as actions from '../actions/exploreActions';
|
||||
import { connect } from 'react-redux';
|
||||
import ChartContainer from './ChartContainer';
|
||||
import ControlPanelsContainer from './ControlPanelsContainer';
|
||||
import SaveModal from './SaveModal';
|
||||
import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns';
|
||||
import { autoQueryFields } from '../stores/fields';
|
||||
import QueryAndSaveBtns from './QueryAndSaveBtns';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromFields } from '../stores/store';
|
||||
|
||||
const propTypes = {
|
||||
form_data: React.PropTypes.object.isRequired,
|
||||
actions: React.PropTypes.object.isRequired,
|
||||
datasource_type: React.PropTypes.string.isRequired,
|
||||
chartStatus: React.PropTypes.string.isRequired,
|
||||
fields: React.PropTypes.object.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
chartStatus: PropTypes.string.isRequired,
|
||||
fields: PropTypes.object.isRequired,
|
||||
forcedHeight: PropTypes.string,
|
||||
form_data: PropTypes.object.isRequired,
|
||||
standalone: PropTypes.bool.isRequired,
|
||||
triggerQuery: PropTypes.bool.isRequired,
|
||||
queryRequest: PropTypes.object,
|
||||
};
|
||||
|
||||
|
||||
class ExploreViewContainer extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -29,17 +32,23 @@ class ExploreViewContainer extends React.Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchDatasources();
|
||||
window.addEventListener('resize', this.handleResize.bind(this));
|
||||
this.runQuery();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const refreshChart = Object.keys(nextProps.form_data).some((field) => (
|
||||
nextProps.form_data[field] !== this.props.form_data[field]
|
||||
&& autoQueryFields.indexOf(field) !== -1)
|
||||
);
|
||||
if (refreshChart) {
|
||||
this.onQuery();
|
||||
componentWillReceiveProps(np) {
|
||||
if (np.fields.viz_type.value !== this.props.fields.viz_type.value) {
|
||||
this.props.actions.resetFields();
|
||||
this.props.actions.triggerQuery();
|
||||
}
|
||||
if (np.fields.datasource.value !== this.props.fields.datasource.value) {
|
||||
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() {
|
||||
// remove alerts when query
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
this.props.actions.removeChartAlert();
|
||||
|
||||
this.runQuery();
|
||||
history.pushState(
|
||||
{},
|
||||
document.title,
|
||||
getExploreUrl(this.props.form_data, this.props.datasource_type)
|
||||
);
|
||||
// remove alerts when query
|
||||
this.props.actions.removeControlPanelAlert();
|
||||
this.props.actions.removeChartAlert();
|
||||
getExploreUrl(this.props.form_data));
|
||||
}
|
||||
|
||||
onStop() {
|
||||
this.props.actions.chartUpdateStopped(this.props.queryRequest);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
|
@ -101,8 +117,18 @@ class ExploreViewContainer extends React.Component {
|
|||
}
|
||||
return errorMessage;
|
||||
}
|
||||
renderChartContainer() {
|
||||
return (
|
||||
<ChartContainer
|
||||
actions={this.props.actions}
|
||||
height={this.state.height}
|
||||
/>);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
return this.renderChartContainer();
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id="explore-container"
|
||||
|
@ -117,7 +143,6 @@ class ExploreViewContainer extends React.Component {
|
|||
onHide={this.toggleModal.bind(this)}
|
||||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
datasource_type={this.props.datasource_type}
|
||||
/>
|
||||
}
|
||||
<div className="row">
|
||||
|
@ -126,7 +151,8 @@ class ExploreViewContainer extends React.Component {
|
|||
canAdd="True"
|
||||
onQuery={this.onQuery.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()}
|
||||
/>
|
||||
<br />
|
||||
|
@ -134,14 +160,10 @@ class ExploreViewContainer extends React.Component {
|
|||
actions={this.props.actions}
|
||||
form_data={this.props.form_data}
|
||||
datasource_type={this.props.datasource_type}
|
||||
onQuery={this.onQuery.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-8">
|
||||
<ChartContainer
|
||||
actions={this.props.actions}
|
||||
height={this.state.height}
|
||||
/>
|
||||
{this.renderChartContainer()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -152,11 +174,16 @@ class ExploreViewContainer extends React.Component {
|
|||
ExploreViewContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const form_data = getFormDataFromFields(state.fields);
|
||||
return {
|
||||
chartStatus: state.chartStatus,
|
||||
datasource_type: state.datasource_type,
|
||||
fields: state.fields,
|
||||
form_data: state.viz.form_data,
|
||||
form_data,
|
||||
standalone: state.standalone,
|
||||
triggerQuery: state.triggerQuery,
|
||||
forcedHeight: state.forced_height,
|
||||
queryRequest: state.queryRequest,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import TextField from './TextField';
|
||||
import CheckboxField from './CheckboxField';
|
||||
import TextAreaField from './TextAreaField';
|
||||
import SelectField from './SelectField';
|
||||
import FilterField from './FilterField';
|
||||
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 = {
|
||||
TextField,
|
||||
CheckboxField,
|
||||
TextAreaField,
|
||||
SelectField,
|
||||
FilterField,
|
||||
HiddenField,
|
||||
SelectField,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
};
|
||||
const fieldTypes = Object.keys(fieldMap);
|
||||
|
||||
|
@ -25,6 +27,8 @@ const propTypes = {
|
|||
places: PropTypes.number,
|
||||
validators: PropTypes.array,
|
||||
validationErrors: PropTypes.array,
|
||||
renderTrigger: PropTypes.bool,
|
||||
rightNode: PropTypes.node,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
|
@ -33,6 +37,7 @@ const propTypes = {
|
|||
};
|
||||
|
||||
const defaultProps = {
|
||||
renderTrigger: false,
|
||||
validators: [],
|
||||
validationErrors: [],
|
||||
};
|
||||
|
@ -65,12 +70,15 @@ export default class FieldSet extends React.PureComponent {
|
|||
}
|
||||
render() {
|
||||
const FieldType = fieldMap[this.props.type];
|
||||
const divStyle = this.props.hidden ? { display: 'none' } : null;
|
||||
return (
|
||||
<div>
|
||||
<div style={divStyle}>
|
||||
<ControlHeader
|
||||
label={this.props.label}
|
||||
description={this.props.description}
|
||||
renderTrigger={this.props.renderTrigger}
|
||||
validationErrors={this.props.validationErrors}
|
||||
rightNode={this.props.rightNode}
|
||||
/>
|
||||
<FieldType
|
||||
onChange={this.onChange}
|
||||
|
|
|
@ -6,14 +6,15 @@ import SelectField from './SelectField';
|
|||
|
||||
const propTypes = {
|
||||
choices: PropTypes.array,
|
||||
opChoices: PropTypes.array,
|
||||
changeFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
filter: PropTypes.object.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
having: PropTypes.bool,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
having: false,
|
||||
changeFilter: () => {},
|
||||
removeFilter: () => {},
|
||||
choices: [],
|
||||
|
@ -21,6 +22,11 @@ const defaultProps = {
|
|||
};
|
||||
|
||||
export default class Filter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.opChoices = this.props.having ? ['==', '!=', '>', '<', '>=', '<=']
|
||||
: ['in', 'not in'];
|
||||
}
|
||||
fetchFilterValues(col) {
|
||||
if (!this.props.datasource) {
|
||||
return;
|
||||
|
@ -61,24 +67,27 @@ export default class Filter extends React.Component {
|
|||
if (!filter.choices) {
|
||||
this.fetchFilterValues(filter.col);
|
||||
}
|
||||
}
|
||||
if (this.props.having) {
|
||||
// druid having filter
|
||||
return (
|
||||
<SelectField
|
||||
multi
|
||||
freeForm
|
||||
name="filter-value"
|
||||
<input
|
||||
type="text"
|
||||
onChange={this.changeFilter.bind(this, 'val')}
|
||||
value={filter.value}
|
||||
choices={filter.choices}
|
||||
onChange={this.changeFilter.bind(this, 'value')}
|
||||
className="form-control input-sm"
|
||||
placeholder="Filter value"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
onChange={this.changeFilter.bind(this, 'value')}
|
||||
value={filter.value}
|
||||
className="form-control input-sm"
|
||||
placeholder="Filter value"
|
||||
<SelectField
|
||||
multi
|
||||
freeForm
|
||||
name="filter-value"
|
||||
value={filter.val}
|
||||
choices={filter.choices || []}
|
||||
onChange={this.changeFilter.bind(this, 'val')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -102,7 +111,7 @@ export default class Filter extends React.Component {
|
|||
<Select
|
||||
id="select-op"
|
||||
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}
|
||||
onChange={this.changeFilter.bind(this, 'op')}
|
||||
/>
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Button, Row, Col } from 'react-bootstrap';
|
|||
import Filter from './Filter';
|
||||
|
||||
const propTypes = {
|
||||
prefix: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
choices: PropTypes.array,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.array,
|
||||
|
@ -11,25 +11,18 @@ const propTypes = {
|
|||
};
|
||||
|
||||
const defaultProps = {
|
||||
prefix: 'flt',
|
||||
choices: [],
|
||||
onChange: () => {},
|
||||
value: [],
|
||||
};
|
||||
|
||||
export default class FilterField extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.opChoices = props.prefix === 'flt' ?
|
||||
['in', 'not in'] : ['==', '!=', '>', '<', '>=', '<='];
|
||||
}
|
||||
addFilter() {
|
||||
const newFilters = Object.assign([], this.props.value);
|
||||
newFilters.push({
|
||||
prefix: this.props.prefix,
|
||||
col: null,
|
||||
op: 'in',
|
||||
value: this.props.datasource.filter_select ? [] : '',
|
||||
val: this.props.datasource.filter_select ? [] : '',
|
||||
});
|
||||
this.props.onChange(newFilters);
|
||||
}
|
||||
|
@ -46,22 +39,19 @@ export default class FilterField extends React.Component {
|
|||
render() {
|
||||
const filters = [];
|
||||
this.props.value.forEach((filter, i) => {
|
||||
// only display filters with current prefix
|
||||
if (filter.prefix === this.props.prefix) {
|
||||
const filterBox = (
|
||||
<div key={i}>
|
||||
<Filter
|
||||
filter={filter}
|
||||
choices={this.props.choices}
|
||||
opChoices={this.opChoices}
|
||||
datasource={this.props.datasource}
|
||||
removeFilter={this.removeFilter.bind(this, i)}
|
||||
changeFilter={this.changeFilter.bind(this, i)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
filters.push(filterBox);
|
||||
}
|
||||
const filterBox = (
|
||||
<div key={i}>
|
||||
<Filter
|
||||
having={this.props.name === 'having_filters'}
|
||||
filter={filter}
|
||||
choices={this.props.choices}
|
||||
datasource={this.props.datasource}
|
||||
removeFilter={this.removeFilter.bind(this, i)}
|
||||
changeFilter={this.changeFilter.bind(this, i)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
filters.push(filterBox);
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
|
|
|
@ -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;
|
|
@ -7,38 +7,50 @@ const propTypes = {
|
|||
canAdd: PropTypes.string.isRequired,
|
||||
onQuery: PropTypes.func.isRequired,
|
||||
onSave: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
onStop: PropTypes.func,
|
||||
loading: PropTypes.bool,
|
||||
errorMessage: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onStop: () => {},
|
||||
onSave: () => {},
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default function QueryAndSaveBtns({ canAdd, onQuery, onSave, disabled, errorMessage }) {
|
||||
export default function QueryAndSaveBtns(
|
||||
{ canAdd, onQuery, onSave, onStop, loading, errorMessage }) {
|
||||
const saveClasses = classnames({
|
||||
'disabled disabledButton': canAdd !== 'True',
|
||||
});
|
||||
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 (
|
||||
<div>
|
||||
<ButtonGroup className="query-and-save">
|
||||
<Button
|
||||
id="query_button"
|
||||
onClick={onQuery}
|
||||
disabled={qryButtonDisabled}
|
||||
bsStyle={qryButtonStyle}
|
||||
>
|
||||
<i className="fa fa-bolt" /> Query
|
||||
</Button>
|
||||
{qryOrStopButton}
|
||||
<Button
|
||||
className={saveClasses}
|
||||
data-target="#save_modal"
|
||||
data-toggle="modal"
|
||||
disabled={qryButtonDisabled}
|
||||
disabled={saveButtonDisabled}
|
||||
onClick={onSave}
|
||||
>
|
||||
<i className="fa fa-plus-circle"></i> Save as
|
|
@ -1,20 +1,20 @@
|
|||
/* eslint camel-case: 0 */
|
||||
/* eslint camelcase: 0 */
|
||||
import React, { PropTypes } from 'react';
|
||||
import $ from 'jquery';
|
||||
import { Modal, Alert, Button, Radio } from 'react-bootstrap';
|
||||
import Select from 'react-select';
|
||||
import { connect } from 'react-redux';
|
||||
import { getParamObject } from '../exploreUtils';
|
||||
|
||||
const propTypes = {
|
||||
can_edit: PropTypes.bool,
|
||||
can_overwrite: PropTypes.bool,
|
||||
onHide: PropTypes.func.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
form_data: PropTypes.object,
|
||||
datasource_type: PropTypes.string.isRequired,
|
||||
user_id: PropTypes.string.isRequired,
|
||||
dashboards: PropTypes.array.isRequired,
|
||||
alert: PropTypes.string,
|
||||
slice: PropTypes.object,
|
||||
datasource: PropTypes.object,
|
||||
};
|
||||
|
||||
class SaveModal extends React.Component {
|
||||
|
@ -26,7 +26,7 @@ class SaveModal extends React.Component {
|
|||
newSliceName: '',
|
||||
dashboards: [],
|
||||
alert: null,
|
||||
action: 'overwrite',
|
||||
action: 'saveas',
|
||||
addToDash: 'noSave',
|
||||
};
|
||||
}
|
||||
|
@ -58,13 +58,13 @@ class SaveModal extends React.Component {
|
|||
saveOrOverwrite(gotodash) {
|
||||
this.setState({ alert: null });
|
||||
this.props.actions.removeSaveModalAlert();
|
||||
const params = getParamObject(
|
||||
this.props.form_data, this.props.datasource_type, this.state.action === 'saveas');
|
||||
const sliceParams = {};
|
||||
params.datasource_name = this.props.form_data.datasource_name;
|
||||
|
||||
let sliceName = null;
|
||||
sliceParams.action = this.state.action;
|
||||
if (this.props.slice.slice_id) {
|
||||
sliceParams.slice_id = this.props.slice.slice_id;
|
||||
}
|
||||
if (sliceParams.action === 'saveas') {
|
||||
sliceName = this.state.newSliceName;
|
||||
if (sliceName === '') {
|
||||
|
@ -73,7 +73,7 @@ class SaveModal extends React.Component {
|
|||
}
|
||||
sliceParams.slice_name = sliceName;
|
||||
} else {
|
||||
sliceParams.slice_name = this.props.form_data.slice_name;
|
||||
sliceParams.slice_name = this.props.slice.slice_name;
|
||||
}
|
||||
|
||||
const addToDash = this.state.addToDash;
|
||||
|
@ -100,9 +100,13 @@ class SaveModal extends React.Component {
|
|||
dashboard = null;
|
||||
}
|
||||
sliceParams.goto_dash = gotodash;
|
||||
const baseUrl = '/superset/explore/' +
|
||||
`${this.props.datasource_type}/${this.props.form_data.datasource}/`;
|
||||
const saveUrl = `${baseUrl}?${$.param(params, true)}&${$.param(sliceParams, true)}`;
|
||||
|
||||
const baseUrl = `/superset/explore/${this.props.datasource.type}/${this.props.datasource.id}/`;
|
||||
sliceParams.datasource_name = this.props.datasource.name;
|
||||
|
||||
const saveUrl = `${baseUrl}?form_data=` +
|
||||
`${encodeURIComponent(JSON.stringify(this.props.form_data))}` +
|
||||
`&${$.param(sliceParams, true)}`;
|
||||
this.props.actions.saveSlice(saveUrl);
|
||||
this.props.onHide();
|
||||
}
|
||||
|
@ -136,11 +140,11 @@ class SaveModal extends React.Component {
|
|||
</Alert>
|
||||
}
|
||||
<Radio
|
||||
disabled={!this.props.can_edit}
|
||||
disabled={!this.props.can_overwrite}
|
||||
checked={this.state.action === 'overwrite'}
|
||||
onChange={this.changeAction.bind(this, 'overwrite')}
|
||||
>
|
||||
{`Overwrite slice ${this.props.form_data.slice_name}`}
|
||||
{`Overwrite slice ${this.props.slice.slice_name}`}
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
|
@ -223,7 +227,9 @@ SaveModal.propTypes = propTypes;
|
|||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
can_edit: state.can_edit,
|
||||
datasource: state.datasource,
|
||||
slice: state.slice,
|
||||
can_overwrite: state.can_overwrite,
|
||||
user_id: state.user_id,
|
||||
dashboards: state.dashboards,
|
||||
alert: state.saveModalAlert,
|
||||
|
|
|
@ -5,8 +5,8 @@ const propTypes = {
|
|||
choices: PropTypes.array,
|
||||
clearable: PropTypes.bool,
|
||||
description: PropTypes.string,
|
||||
editUrl: PropTypes.string,
|
||||
freeForm: PropTypes.bool,
|
||||
isLoading: PropTypes.bool,
|
||||
label: PropTypes.string,
|
||||
multi: PropTypes.bool,
|
||||
name: PropTypes.string.isRequired,
|
||||
|
@ -18,21 +18,26 @@ const defaultProps = {
|
|||
choices: [],
|
||||
clearable: true,
|
||||
description: null,
|
||||
editUrl: null,
|
||||
freeForm: false,
|
||||
isLoading: false,
|
||||
label: null,
|
||||
multi: false,
|
||||
onChange: () => {},
|
||||
value: '',
|
||||
};
|
||||
|
||||
export default class SelectField extends React.Component {
|
||||
export default class SelectField extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { options: this.getOptions() };
|
||||
this.state = { options: this.getOptions(props) };
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.renderOption = this.renderOption.bind(this);
|
||||
}
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.choices !== this.props.choices) {
|
||||
const options = this.getOptions(nextProps);
|
||||
this.setState({ options });
|
||||
}
|
||||
}
|
||||
onChange(opt) {
|
||||
let optionValue = opt ? opt.value : null;
|
||||
// if multi, return options values as an array
|
||||
|
@ -41,8 +46,8 @@ export default class SelectField extends React.Component {
|
|||
}
|
||||
this.props.onChange(optionValue);
|
||||
}
|
||||
getOptions() {
|
||||
const options = this.props.choices.map((c) => {
|
||||
getOptions(props) {
|
||||
const options = props.choices.map((c) => {
|
||||
const label = c.length > 1 ? c[1] : c[0];
|
||||
const newOptions = {
|
||||
value: c[0],
|
||||
|
@ -51,19 +56,19 @@ export default class SelectField extends React.Component {
|
|||
if (c[2]) newOptions.imgSrc = c[2];
|
||||
return newOptions;
|
||||
});
|
||||
if (this.props.freeForm) {
|
||||
if (props.freeForm) {
|
||||
// For FreeFormSelect, insert value into options if not exist
|
||||
const values = this.props.choices.map((c) => c[0]);
|
||||
if (this.props.value) {
|
||||
if (typeof this.props.value === 'object') {
|
||||
this.props.value.forEach((v) => {
|
||||
const values = props.choices.map((c) => c[0]);
|
||||
if (props.value) {
|
||||
if (typeof props.value === 'object') {
|
||||
props.value.forEach((v) => {
|
||||
if (values.indexOf(v) === -1) {
|
||||
options.push({ value: v, label: v });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (values.indexOf(this.props.value) === -1) {
|
||||
options.push({ value: this.props.value, label: this.props.value });
|
||||
if (values.indexOf(props.value) === -1) {
|
||||
options.push({ value: props.value, label: props.value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -91,6 +96,7 @@ export default class SelectField extends React.Component {
|
|||
value: this.props.value,
|
||||
autosize: false,
|
||||
clearable: this.props.clearable,
|
||||
isLoading: this.props.isLoading,
|
||||
onChange: this.onChange,
|
||||
optionRenderer: this.renderOption,
|
||||
};
|
||||
|
@ -100,9 +106,6 @@ export default class SelectField extends React.Component {
|
|||
return (
|
||||
<div>
|
||||
{selectWrap}
|
||||
{this.props.editUrl &&
|
||||
<a href={`${this.props.editUrl}/${this.props.value}`}>edit</a>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,55 +1,19 @@
|
|||
/* eslint camelcase: 0 */
|
||||
const $ = require('jquery');
|
||||
function formatFilters(filters) {
|
||||
// outputs an object of url params of filters
|
||||
// prefix can be 'flt' or 'having'
|
||||
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)}`;
|
||||
export function getExploreUrl(form_data, dummy, endpoint = 'base') {
|
||||
const [datasource_id, datasource_type] = form_data.datasource.split('__');
|
||||
let params = `${datasource_type}/${datasource_id}/`;
|
||||
params += '?form_data=' + encodeURIComponent(JSON.stringify(form_data));
|
||||
switch (endpoint) {
|
||||
case 'base':
|
||||
return `/superset/explore/${params}`;
|
||||
case 'json':
|
||||
return `/superset/explore_json/${params}`;
|
||||
case 'csv':
|
||||
return `/superset/explore/${params}&csv=true`;
|
||||
return `/superset/explore_json/${params}&csv=true`;
|
||||
case 'standalone':
|
||||
return `/superset/explore/${params}&standalone=true`;
|
||||
case 'query':
|
||||
return `/superset/explore_json/${params}&query=true`;
|
||||
default:
|
||||
return `/superset/explore/${params}`;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import { Provider } from 'react-redux';
|
|||
import thunk from 'redux-thunk';
|
||||
import { now } from '../modules/dates';
|
||||
import { initEnhancer } from '../reduxUtils';
|
||||
import { getFieldsState, getFormDataFromFields } from './stores/store';
|
||||
|
||||
|
||||
// jquery and bootstrap required to make bootstrap dropdown menu's work
|
||||
const $ = window.$ = require('jquery'); // eslint-disable-line
|
||||
|
@ -14,58 +16,30 @@ const jQuery = window.jQuery = require('jquery'); // eslint-disable-line
|
|||
require('bootstrap');
|
||||
require('./main.css');
|
||||
|
||||
import { initialState } from './stores/store';
|
||||
|
||||
const exploreViewContainer = document.getElementById('js-explore-view-container');
|
||||
const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap'));
|
||||
const fields = getFieldsState(bootstrapData, bootstrapData.form_data);
|
||||
delete bootstrapData.form_data;
|
||||
|
||||
import { exploreReducer } from './reducers/exploreReducer';
|
||||
|
||||
// Initial state
|
||||
const bootstrappedState = Object.assign(
|
||||
initialState(bootstrapData.viz.form_data.viz_type, bootstrapData.datasource_type), {
|
||||
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,
|
||||
bootstrapData, {
|
||||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
dashboards: [],
|
||||
fields,
|
||||
latestQueryFormData: getFormDataFromFields(fields),
|
||||
filterColumnOpts: [],
|
||||
isDatasourceMetaLoading: false,
|
||||
isStarred: false,
|
||||
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,
|
||||
compose(applyMiddleware(thunk), initEnhancer(false))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint camelcase: 0 */
|
||||
import { defaultFormData } from '../stores/store';
|
||||
import { getFieldsState, getFormDataFromFields } from '../stores/store';
|
||||
import * as actions from '../actions/exploreActions';
|
||||
import { now } from '../../modules/dates';
|
||||
|
||||
|
@ -9,15 +9,15 @@ export const exploreReducer = function (state, action) {
|
|||
return Object.assign({}, state, { isStarred: action.isStarred });
|
||||
},
|
||||
|
||||
[actions.FETCH_STARTED]() {
|
||||
[actions.FETCH_DATASOURCE_STARTED]() {
|
||||
return Object.assign({}, state, { isDatasourceMetaLoading: true });
|
||||
},
|
||||
|
||||
[actions.FETCH_SUCCEEDED]() {
|
||||
[actions.FETCH_DATASOURCE_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { isDatasourceMetaLoading: false });
|
||||
},
|
||||
|
||||
[actions.FETCH_FAILED]() {
|
||||
[actions.FETCH_DATASOURCE_FAILED]() {
|
||||
// todo(alanna) handle failure/error state
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
|
@ -25,6 +25,28 @@ export const exploreReducer = function (state, action) {
|
|||
controlPanelAlert: action.error,
|
||||
});
|
||||
},
|
||||
[actions.SET_DATASOURCE]() {
|
||||
return Object.assign({}, state, { datasource: action.datasource });
|
||||
},
|
||||
[actions.FETCH_DATASOURCES_STARTED]() {
|
||||
return Object.assign({}, state, { isDatasourcesLoading: true });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCES_SUCCEEDED]() {
|
||||
return Object.assign({}, state, { isDatasourcesLoading: false });
|
||||
},
|
||||
|
||||
[actions.FETCH_DATASOURCES_FAILED]() {
|
||||
// todo(alanna) handle failure/error state
|
||||
return Object.assign({}, state,
|
||||
{
|
||||
isDatasourcesLoading: false,
|
||||
controlPanelAlert: action.error,
|
||||
});
|
||||
},
|
||||
[actions.SET_DATASOURCES]() {
|
||||
return Object.assign({}, state, { datasources: action.datasources });
|
||||
},
|
||||
[actions.REMOVE_CONTROL_PANEL_ALERT]() {
|
||||
return Object.assign({}, state, { controlPanelAlert: null });
|
||||
},
|
||||
|
@ -36,32 +58,17 @@ export const exploreReducer = function (state, action) {
|
|||
return Object.assign({}, state,
|
||||
{ saveModalAlert: `fetching dashboards failed for ${action.userId}` });
|
||||
},
|
||||
[actions.SET_DATASOURCE]() {
|
||||
return Object.assign({}, state, { datasource: action.datasource });
|
||||
},
|
||||
[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 field = fields[action.fieldName];
|
||||
const field = Object.assign({}, fields[action.fieldName]);
|
||||
field.value = action.value;
|
||||
field.validationErrors = action.validationErrors;
|
||||
return Object.assign(
|
||||
{},
|
||||
state,
|
||||
{
|
||||
fields,
|
||||
viz: Object.assign({}, state.viz, { form_data: newFormData }),
|
||||
}
|
||||
);
|
||||
fields[action.fieldName] = field;
|
||||
const changes = { fields };
|
||||
if (field.renderTrigger) {
|
||||
changes.triggerRender = true;
|
||||
}
|
||||
return Object.assign({}, state, changes);
|
||||
},
|
||||
[actions.CHART_UPDATE_SUCCEEDED]() {
|
||||
return Object.assign(
|
||||
|
@ -79,6 +86,16 @@ export const exploreReducer = function (state, action) {
|
|||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
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]() {
|
||||
|
@ -87,10 +104,15 @@ export const exploreReducer = function (state, action) {
|
|||
chartAlert: 'An error occurred while rendering the visualization: ' + action.error,
|
||||
});
|
||||
},
|
||||
[actions.TRIGGER_QUERY]() {
|
||||
return Object.assign({}, state, {
|
||||
triggerQuery: true,
|
||||
});
|
||||
},
|
||||
[actions.CHART_UPDATE_FAILED]() {
|
||||
return Object.assign({}, state, {
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse.error,
|
||||
chartAlert: action.queryResponse ? action.queryResponse.error : 'Network error.',
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
});
|
||||
|
@ -114,6 +136,13 @@ export const exploreReducer = function (state, action) {
|
|||
[actions.REMOVE_SAVE_MODAL_ALERT]() {
|
||||
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) {
|
||||
return actionHandlers[action.type]();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { formatSelectOptionsForRange, formatSelectOptions } from '../../modules/utils';
|
||||
import React from 'react';
|
||||
import visTypes from './visTypes';
|
||||
import * as v from '../validators';
|
||||
|
||||
|
@ -26,21 +27,23 @@ export const TIME_STAMP_OPTIONS = [
|
|||
['%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 = {
|
||||
datasource: {
|
||||
type: 'SelectField',
|
||||
label: 'Datasource',
|
||||
isLoading: true,
|
||||
clearable: false,
|
||||
default: null,
|
||||
mapStateToProps: (state) => ({
|
||||
choices: state.datasources || [],
|
||||
editUrl: MAP_DATASOURCE_TYPE_TO_EDIT_URL[state.datasource_type],
|
||||
}),
|
||||
mapStateToProps: (state) => {
|
||||
const datasources = state.datasources || [];
|
||||
return {
|
||||
choices: datasources,
|
||||
isLoading: datasources.length === 0,
|
||||
rightNode: state.datasource ?
|
||||
<a href={state.datasource.edit_url}>edit</a>
|
||||
: null,
|
||||
};
|
||||
},
|
||||
description: '',
|
||||
},
|
||||
|
||||
|
@ -62,10 +65,10 @@ export const fields = {
|
|||
multi: true,
|
||||
label: 'Metrics',
|
||||
validators: [v.nonEmpty],
|
||||
default: field => field.choices !== null ? [field.choices[0][0]] : null,
|
||||
mapStateToProps: (state) => ({
|
||||
choices: (state.datasource) ? state.datasource.metrics_combo : [],
|
||||
}),
|
||||
default: [],
|
||||
description: 'One or many metrics to display',
|
||||
},
|
||||
|
||||
|
@ -83,10 +86,11 @@ export const fields = {
|
|||
metric: {
|
||||
type: 'SelectField',
|
||||
label: 'Metric',
|
||||
default: null,
|
||||
clearable: false,
|
||||
description: 'Choose the metric',
|
||||
default: field => field.choices && field.choices.length > 0 ? field.choices[0][0] : null,
|
||||
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: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Stacked Bars',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: null,
|
||||
},
|
||||
|
@ -192,6 +197,7 @@ export const fields = {
|
|||
show_markers: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Show Markers',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Show data points as circle markers on the lines',
|
||||
},
|
||||
|
@ -200,6 +206,7 @@ export const fields = {
|
|||
type: 'CheckboxField',
|
||||
label: 'Bar Values',
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: 'Show the value on top of the bar',
|
||||
},
|
||||
|
||||
|
@ -213,6 +220,7 @@ export const fields = {
|
|||
show_controls: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Extra Controls',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Whether to show extra controls or not. Extra controls ' +
|
||||
'include things like making mulitBar charts stacked ' +
|
||||
|
@ -222,6 +230,7 @@ export const fields = {
|
|||
reduce_x_ticks: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Reduce X ticks',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Reduces the number of X axis ticks to be rendered. ' +
|
||||
'If true, the x axis wont overflow and labels may be ' +
|
||||
|
@ -233,6 +242,7 @@ export const fields = {
|
|||
include_series: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Include Series',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Include series name as an axis',
|
||||
},
|
||||
|
@ -276,7 +286,9 @@ export const fields = {
|
|||
type: 'SelectField',
|
||||
multi: true,
|
||||
label: 'Columns',
|
||||
choices: [],
|
||||
mapStateToProps: (state) => ({
|
||||
choices: (state.datasource) ? state.datasource.gb_cols : [],
|
||||
}),
|
||||
default: [],
|
||||
description: 'One or many fields to pivot as columns',
|
||||
},
|
||||
|
@ -408,28 +420,28 @@ export const fields = {
|
|||
granularity_sqla: {
|
||||
type: 'SelectField',
|
||||
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 ' +
|
||||
'can define arbitrary expression that return a DATETIME ' +
|
||||
'column in the table or. Also note that the ' +
|
||||
'filter below is applied against this column or ' +
|
||||
'expression',
|
||||
mapStateToProps: (state) => ({
|
||||
choices: (state.datasource) ? state.datasource.all_cols : [],
|
||||
choices: (state.datasource) ? state.datasource.granularity_sqla : [],
|
||||
}),
|
||||
},
|
||||
|
||||
time_grain_sqla: {
|
||||
type: 'SelectField',
|
||||
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 ' +
|
||||
'applies a date transformation to alter ' +
|
||||
'your time column and defines a new time granularity. ' +
|
||||
'The options here are defined on a per database ' +
|
||||
'engine basis in the Superset source code.',
|
||||
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,
|
||||
description: 'Metric assigned to the [X] axis',
|
||||
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: {
|
||||
type: 'TextField',
|
||||
label: 'X Axis Label',
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
},
|
||||
|
||||
y_axis_label: {
|
||||
type: 'TextField',
|
||||
label: 'Y Axis Label',
|
||||
renderTrigger: true,
|
||||
default: '',
|
||||
},
|
||||
|
||||
|
@ -712,6 +726,7 @@ export const fields = {
|
|||
type: 'SelectField',
|
||||
freeForm: true,
|
||||
label: 'X axis format',
|
||||
renderTrigger: true,
|
||||
default: 'smart_date',
|
||||
choices: TIME_STAMP_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
|
@ -721,6 +736,7 @@ export const fields = {
|
|||
type: 'SelectField',
|
||||
freeForm: true,
|
||||
label: 'Y axis format',
|
||||
renderTrigger: true,
|
||||
default: '.3s',
|
||||
choices: D3_TIME_FORMAT_OPTIONS,
|
||||
description: D3_FORMAT_DOCS,
|
||||
|
@ -754,6 +770,7 @@ export const fields = {
|
|||
line_interpolation: {
|
||||
type: 'SelectField',
|
||||
label: 'Line Style',
|
||||
renderTrigger: true,
|
||||
choices: formatSelectOptions(['linear', 'basis', 'cardinal',
|
||||
'monotone', 'step-before', 'step-after']),
|
||||
default: 'linear',
|
||||
|
@ -782,6 +799,7 @@ export const fields = {
|
|||
pandas_aggfunc: {
|
||||
type: 'SelectField',
|
||||
label: 'Aggregation function',
|
||||
clearable: false,
|
||||
choices: formatSelectOptions([
|
||||
'sum',
|
||||
'mean',
|
||||
|
@ -815,6 +833,7 @@ export const fields = {
|
|||
show_brush: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Range Filter',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Whether to display the time range interactive selector',
|
||||
},
|
||||
|
@ -836,6 +855,7 @@ export const fields = {
|
|||
include_search: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Search Box',
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: 'Whether to include a client side search box',
|
||||
},
|
||||
|
@ -851,12 +871,14 @@ export const fields = {
|
|||
type: 'CheckboxField',
|
||||
label: 'Show Bubbles',
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: 'Whether to display bubbles on top of countries',
|
||||
},
|
||||
|
||||
show_legend: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Legend',
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: 'Whether to display the legend (toggles)',
|
||||
},
|
||||
|
@ -864,6 +886,7 @@ export const fields = {
|
|||
x_axis_showminmax: {
|
||||
type: 'CheckboxField',
|
||||
label: 'X bounds',
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: 'Whether to display the min and max values of the X axis',
|
||||
},
|
||||
|
@ -871,6 +894,7 @@ export const fields = {
|
|||
rich_tooltip: {
|
||||
type: 'CheckboxField',
|
||||
label: 'Rich Tooltip',
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: 'The rich tooltip shows a list of all series for that ' +
|
||||
'point in time',
|
||||
|
@ -880,6 +904,7 @@ export const fields = {
|
|||
type: 'CheckboxField',
|
||||
label: 'Y Axis Zero',
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: 'Force the Y axis to start at 0 instead of the minimum value',
|
||||
},
|
||||
|
||||
|
@ -887,6 +912,7 @@ export const fields = {
|
|||
type: 'CheckboxField',
|
||||
label: 'Y Log Scale',
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: 'Use a log scale for the Y axis',
|
||||
},
|
||||
|
||||
|
@ -894,6 +920,7 @@ export const fields = {
|
|||
type: 'CheckboxField',
|
||||
label: 'X Log Scale',
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: 'Use a log scale for the X axis',
|
||||
},
|
||||
|
||||
|
@ -1005,12 +1032,12 @@ export const fields = {
|
|||
point_radius: {
|
||||
type: 'SelectField',
|
||||
label: 'Point Radius',
|
||||
default: null,
|
||||
default: 'Auto',
|
||||
description: 'The radius of individual points (ones that are not in a cluster). ' +
|
||||
'Either a numerical column or `Auto`, which scales the point based ' +
|
||||
'on the largest cluster',
|
||||
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,
|
||||
}),
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
// 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',
|
||||
];
|
|
@ -1,56 +1,111 @@
|
|||
/* eslint camelcase: 0 */
|
||||
import { sectionsToRender } from './visTypes';
|
||||
import fields from './fields';
|
||||
import visTypes, { sectionsToRender } from './visTypes';
|
||||
|
||||
export function defaultFormData(vizType = 'table', datasourceType = 'table') {
|
||||
const data = {
|
||||
slice_name: null,
|
||||
slice_id: null,
|
||||
datasource_name: null,
|
||||
filters: [],
|
||||
};
|
||||
const sections = sectionsToRender(vizType, datasourceType);
|
||||
sections.forEach((section) => {
|
||||
section.fieldSetRows.forEach((fieldSetRow) => {
|
||||
fieldSetRow.forEach((k) => {
|
||||
data[k] = fields[k].default;
|
||||
});
|
||||
});
|
||||
export function getFormDataFromFields(fieldsState) {
|
||||
const formData = {};
|
||||
Object.keys(fieldsState).forEach(fieldName => {
|
||||
formData[fieldName] = fieldsState[fieldName].value;
|
||||
});
|
||||
return data;
|
||||
return formData;
|
||||
}
|
||||
|
||||
export function defaultViz(vizType, datasourceType = 'table') {
|
||||
return {
|
||||
cached_key: null,
|
||||
cached_timeout: null,
|
||||
cached_dttm: null,
|
||||
column_formats: null,
|
||||
csv_endpoint: null,
|
||||
is_cached: false,
|
||||
data: [],
|
||||
form_data: defaultFormData(vizType, datasourceType),
|
||||
json_endpoint: null,
|
||||
query: null,
|
||||
standalone_endpoint: null,
|
||||
};
|
||||
export function getFieldNames(vizType, datasourceType) {
|
||||
const fieldNames = [];
|
||||
sectionsToRender(vizType, datasourceType).forEach(
|
||||
section => section.fieldSetRows.forEach(
|
||||
fsr => fsr.forEach(
|
||||
f => fieldNames.push(f))));
|
||||
return fieldNames;
|
||||
}
|
||||
|
||||
export function initialState(vizType = 'table', datasourceType = 'table') {
|
||||
return {
|
||||
dashboards: [],
|
||||
isDatasourceMetaLoading: false,
|
||||
datasources: null,
|
||||
datasource_type: null,
|
||||
filterColumnOpts: [],
|
||||
fields,
|
||||
viz: defaultViz(vizType, datasourceType),
|
||||
isStarred: false,
|
||||
};
|
||||
export function getFieldsState(state, form_data) {
|
||||
/*
|
||||
* Gets a new fields object to put in the state. The fields object
|
||||
* is similar to the configuration field with only the fields
|
||||
* related to the current viz_type, materializes mapStateToProps functions,
|
||||
* adds value keys coming from form_data passed here. This can't be an action creator
|
||||
* just yet because it's used in both the explore and dashboard views.
|
||||
* */
|
||||
|
||||
// Getting a list of active 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 = [
|
||||
'datasource',
|
||||
'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 };
|
||||
|
|
|
@ -12,6 +12,7 @@ export const commonControlPanelSections = {
|
|||
fieldSetRows: [
|
||||
['datasource'],
|
||||
['viz_type'],
|
||||
['slice_id'],
|
||||
],
|
||||
},
|
||||
sqlaTimeSeries: {
|
||||
|
@ -60,15 +61,13 @@ export const commonControlPanelSections = {
|
|||
'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>",
|
||||
prefix: 'flt',
|
||||
fieldSetRows: [['filters']],
|
||||
},
|
||||
{
|
||||
label: 'Result Filters',
|
||||
description: 'The filters to apply after post-aggregation.' +
|
||||
'Leave the value field empty to filter empty strings or nulls',
|
||||
prefix: 'having',
|
||||
fieldSetRows: [['filters']],
|
||||
fieldSetRows: [['having_filters']],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -250,8 +249,7 @@ const visTypes = {
|
|||
label: 'Options',
|
||||
fieldSetRows: [
|
||||
['table_timestamp_format'],
|
||||
['row_limit'],
|
||||
['page_length'],
|
||||
['row_limit', 'page_length'],
|
||||
['include_search', 'table_filter'],
|
||||
],
|
||||
},
|
||||
|
@ -433,6 +431,7 @@ const visTypes = {
|
|||
},
|
||||
|
||||
big_number_total: {
|
||||
label: 'Big Number',
|
||||
controlPanelSections: [
|
||||
{
|
||||
label: null,
|
||||
|
@ -758,12 +757,11 @@ export function sectionsToRender(vizType, datasourceType) {
|
|||
const { datasourceAndVizType, sqlClause, filters } = commonControlPanelSections;
|
||||
const filtersToRender =
|
||||
datasourceType === 'table' ? filters[0] : filters;
|
||||
const sections = [].concat(
|
||||
return [].concat(
|
||||
datasourceAndVizType,
|
||||
timeSection,
|
||||
viz.controlPanelSections,
|
||||
sqlClause,
|
||||
filtersToRender
|
||||
);
|
||||
return sections;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ const utils = require('./utils');
|
|||
// vis sources
|
||||
/* eslint camel-case: 0 */
|
||||
import vizMap from '../../visualizations/main.js';
|
||||
import { getExploreUrl } from '../explorev2/exploreUtils';
|
||||
import { applyDefaultFormData } from '../explorev2/stores/store';
|
||||
|
||||
/* eslint wrap-iife: 0*/
|
||||
const px = function () {
|
||||
|
@ -55,12 +57,14 @@ const px = function () {
|
|||
}
|
||||
const Slice = function (data, controller) {
|
||||
let timer;
|
||||
const token = $('#' + data.token);
|
||||
const containerId = data.token + '_con';
|
||||
const token = $('#token_' + data.slice_id);
|
||||
const containerId = 'con_' + data.slice_id;
|
||||
const selector = '#' + containerId;
|
||||
const container = $(selector);
|
||||
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;
|
||||
const stopwatch = function () {
|
||||
dttm += 10;
|
||||
|
@ -70,12 +74,13 @@ const px = function () {
|
|||
let qrystr = '';
|
||||
slice = {
|
||||
data,
|
||||
formData,
|
||||
container,
|
||||
containerId,
|
||||
selector,
|
||||
querystring() {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = data.json_endpoint;
|
||||
parser.href = jsonEndpoint;
|
||||
if (controller.type === 'dashboard') {
|
||||
parser.href = origJsonEndpoint;
|
||||
let flts = controller.effectiveExtraFilters(sliceId);
|
||||
|
@ -100,7 +105,7 @@ const px = function () {
|
|||
},
|
||||
jsonEndpoint() {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = data.json_endpoint;
|
||||
parser.href = jsonEndpoint;
|
||||
let endpoint = parser.pathname + this.querystring();
|
||||
if (endpoint.charAt(0) !== '/') {
|
||||
// Known issue for IE <= 11:
|
||||
|
@ -114,8 +119,11 @@ const px = function () {
|
|||
d3format(col, number) {
|
||||
// uses the utils memoized d3format function and formats based on
|
||||
// column level defined preferences
|
||||
const format = data.column_formats[col];
|
||||
return utils.d3format(format, number);
|
||||
if (data.column_formats) {
|
||||
const format = data.column_formats[col];
|
||||
return utils.d3format(format, number);
|
||||
}
|
||||
return utils.d3format('.3s', number);
|
||||
},
|
||||
/* eslint no-shadow: 0 */
|
||||
always(data) {
|
||||
|
@ -224,7 +232,7 @@ const px = function () {
|
|||
$('#timer').addClass('label-warning');
|
||||
$.getJSON(this.jsonEndpoint(), queryResponse => {
|
||||
try {
|
||||
vizMap[data.form_data.viz_type](this, queryResponse);
|
||||
vizMap[formData.viz_type](this, queryResponse);
|
||||
this.done(queryResponse);
|
||||
} catch (e) {
|
||||
this.error('An error occurred while rendering the visualization: ' + e);
|
||||
|
|
|
@ -133,14 +133,14 @@ export function formatSelectOptionsForRange(start, end) {
|
|||
// returns [[1,1], [2,2], [3,3], [4,4], [5,5]]
|
||||
const options = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
options.push([i.toString(), i.toString()]);
|
||||
options.push([i, i.toString()]);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function formatSelectOptions(options) {
|
||||
return options.map((opt) =>
|
||||
[opt.toString(), opt.toString()]
|
||||
[opt, opt.toString()]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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');
|
|
@ -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();
|
|
@ -4,6 +4,7 @@ cd "$(dirname "$0")"
|
|||
npm --version
|
||||
node --version
|
||||
npm install
|
||||
npm run sync-backend
|
||||
npm run lint
|
||||
npm run test
|
||||
npm run build
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
"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",
|
||||
"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": {
|
||||
"type": "git",
|
||||
|
@ -40,7 +41,7 @@
|
|||
"autobind-decorator": "^1.3.3",
|
||||
"bootstrap": "^3.3.6",
|
||||
"bootstrap-datepicker": "^1.6.0",
|
||||
"brace": "^0.8.0",
|
||||
"brace": "^0.9.1",
|
||||
"brfs": "^1.4.3",
|
||||
"cal-heatmap": "3.6.2",
|
||||
"classnames": "^2.2.5",
|
||||
|
@ -57,7 +58,6 @@
|
|||
"immutability-helper": "^2.0.0",
|
||||
"immutable": "^3.8.1",
|
||||
"jquery": "^2.2.1",
|
||||
"jquery-ui": "1.10.5",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mapbox-gl": "^0.26.0",
|
||||
"moment": "^2.14.1",
|
||||
|
@ -65,7 +65,7 @@
|
|||
"mustache": "^2.2.1",
|
||||
"nvd3": "1.8.5",
|
||||
"react": "^15.3.2",
|
||||
"react-ace": "^3.4.1",
|
||||
"react-ace": "^4.1.5",
|
||||
"react-bootstrap": "^0.30.3",
|
||||
"react-bootstrap-table": "^2.3.8",
|
||||
"react-dom": "^15.3.2",
|
||||
|
@ -73,21 +73,19 @@
|
|||
"react-gravatar": "^2.6.1",
|
||||
"react-grid-layout": "^0.13.1",
|
||||
"react-map-gl": "^1.7.0",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-redux": "^5.0.2",
|
||||
"react-resizable": "^1.3.3",
|
||||
"react-select": "^1.0.0-rc.2",
|
||||
"react-syntax-highlighter": "^2.3.0",
|
||||
"react-syntax-highlighter": "^5.0.0",
|
||||
"reactable": "^0.14.0",
|
||||
"redux": "^3.5.2",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
"select2": "3.5",
|
||||
"select2-bootstrap-css": "^1.4.6",
|
||||
"shortid": "^2.2.6",
|
||||
"style-loader": "^0.13.0",
|
||||
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
|
||||
"topojson": "^1.6.22",
|
||||
"victory": "^0.12.1",
|
||||
"victory": "^0.17.0",
|
||||
"viewport-mercator-project": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { it, describe } from 'mocha';
|
||||
import { expect } from 'chai';
|
||||
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';
|
||||
|
||||
describe('reducers', () => {
|
||||
it('sets correct field value given a key and value', () => {
|
||||
const newState = exploreReducer(
|
||||
initialState('dist_bar'), actions.setFieldValue('x_axis_label', 'x'));
|
||||
expect(newState.viz.form_data.x_axis_label).to.equal('x');
|
||||
defaultState, actions.setFieldValue('x_axis_label', 'x', []));
|
||||
expect(newState.fields.x_axis_label.value).to.equal('x');
|
||||
});
|
||||
it('setFieldValue works as expected with a checkbox', () => {
|
||||
const newState = exploreReducer(initialState('dist_bar'),
|
||||
actions.setFieldValue('show_legend', true));
|
||||
expect(newState.viz.form_data.show_legend).to.equal(true);
|
||||
const newState = exploreReducer(defaultState,
|
||||
actions.setFieldValue('show_legend', true, []));
|
||||
expect(newState.fields.show_legend.value).to.equal(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,24 +3,19 @@ import { expect } from 'chai';
|
|||
import { describe, it, beforeEach } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Panel } from 'react-bootstrap';
|
||||
import { defaultFormData, initialState } from '../../../../javascripts/explorev2/stores/store';
|
||||
|
||||
import { getFormDataFromFields, defaultFields }
|
||||
from '../../../../javascripts/explorev2/stores/store';
|
||||
import {
|
||||
ControlPanelsContainer,
|
||||
} from '../../../../javascripts/explorev2/components/ControlPanelsContainer';
|
||||
import { fields } from '../../../../javascripts/explorev2/stores/fields';
|
||||
|
||||
const defaultProps = {
|
||||
datasource_id: 1,
|
||||
datasource_type: 'type',
|
||||
exploreState: initialState(),
|
||||
form_data: defaultFormData(),
|
||||
fields,
|
||||
actions: {
|
||||
fetchFieldOptions: () => {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
datasource_type: 'table',
|
||||
actions: {},
|
||||
fields: defaultFields,
|
||||
form_data: getFormDataFromFields(defaultFields),
|
||||
isDatasourceMetaLoading: false,
|
||||
exploreState: {},
|
||||
};
|
||||
|
||||
describe('ControlPanelsContainer', () => {
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
|
||||
import DisplayQueryButton from '../../../../javascripts/explore/components/DisplayQueryButton';
|
||||
import DisplayQueryButton from '../../../../javascripts/explorev2/components/DisplayQueryButton';
|
||||
|
||||
describe('DisplayQueryButton', () => {
|
||||
const defaultProps = {
|
|
@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
|
|||
import { shallow, mount } from 'enzyme';
|
||||
import { OverlayTrigger } from 'react-bootstrap';
|
||||
|
||||
import EmbedCodeButton from '../../../../javascripts/explore/components/EmbedCodeButton';
|
||||
import EmbedCodeButton from '../../../../javascripts/explorev2/components/EmbedCodeButton';
|
||||
|
||||
describe('EmbedCodeButton', () => {
|
||||
const defaultProps = {
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
|||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import ExploreActionButtons from '../../../../javascripts/explore/components/ExploreActionButtons';
|
||||
import ExploreActionButtons from
|
||||
'../../../../javascripts/explorev2/components/ExploreActionButtons';
|
||||
|
||||
describe('ExploreActionButtons', () => {
|
||||
const defaultProps = {
|
|
@ -7,6 +7,7 @@ import { expect } from 'chai';
|
|||
import { describe, it, beforeEach } from 'mocha';
|
||||
import { shallow } from 'enzyme';
|
||||
import Filter from '../../../../javascripts/explorev2/components/Filter';
|
||||
import SelectField from '../../../../javascripts/explorev2/components/SelectField';
|
||||
|
||||
const defaultProps = {
|
||||
choices: ['country_name'],
|
||||
|
@ -16,8 +17,6 @@ const defaultProps = {
|
|||
// noop
|
||||
},
|
||||
filter: {
|
||||
id: 1,
|
||||
prefix: 'flt',
|
||||
col: null,
|
||||
op: 'in',
|
||||
value: '',
|
||||
|
@ -45,7 +44,7 @@ describe('Filter', () => {
|
|||
it('renders two selects, one button and one input', () => {
|
||||
expect(wrapper.find(Select)).to.have.lengthOf(2);
|
||||
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', () => {
|
||||
|
@ -53,8 +52,8 @@ describe('Filter', () => {
|
|||
selectCol.simulate('change', { value: 'col' });
|
||||
const selectOp = wrapper.find('#select-op');
|
||||
selectOp.simulate('change', { value: 'in' });
|
||||
const input = wrapper.find('input');
|
||||
input.simulate('change', { target: { value: 'x' } });
|
||||
const selectVal = wrapper.find(SelectField);
|
||||
selectVal.simulate('change', { value: 'x' });
|
||||
expect(defaultProps.changeFilter).to.have.property('callCount', 3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ import { expect } from 'chai';
|
|||
import { shallow } from 'enzyme';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import QueryAndSaveButtons from '../../../../javascripts/explore/components/QueryAndSaveBtns';
|
||||
import QueryAndSaveButtons from '../../../../javascripts/explorev2/components/QueryAndSaveBtns';
|
||||
import Button from '../../../../javascripts/components/Button';
|
||||
|
||||
describe('QueryAndSaveButtons', () => {
|
||||
|
@ -36,7 +36,7 @@ describe('QueryAndSaveButtons', () => {
|
|||
});
|
||||
|
||||
it('calls onQuery when query button is clicked', () => {
|
||||
const queryButton = wrapper.find('#query_button');
|
||||
const queryButton = wrapper.find('.query');
|
||||
queryButton.simulate('click');
|
||||
expect(defaultProps.onQuery.called).to.eql(true);
|
||||
});
|
|
@ -14,10 +14,8 @@ const defaultProps = {
|
|||
saveSlice: sinon.spy(),
|
||||
},
|
||||
form_data: defaultFormData,
|
||||
datasource_id: 1,
|
||||
datasource_name: 'birth_names',
|
||||
datasource_type: 'table',
|
||||
user_id: 1,
|
||||
slice: {},
|
||||
};
|
||||
|
||||
describe('SaveModal', () => {
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
|
||||
import URLShortLinkButton from '../../../../javascripts/explore/components/URLShortLinkButton';
|
||||
import URLShortLinkButton from '../../../../javascripts/explorev2/components/URLShortLinkButton';
|
||||
|
||||
describe('URLShortLinkButton', () => {
|
||||
const defaultProps = {
|
|
@ -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));
|
|
@ -7,8 +7,7 @@ function bigNumberVis(slice, payload) {
|
|||
const div = d3.select(slice.selector);
|
||||
// Define the percentage bounds that define color from red to green
|
||||
div.html(''); // reset
|
||||
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
const json = payload.data;
|
||||
|
||||
const f = d3.format(fd.y_axis_format);
|
||||
|
|
|
@ -8,8 +8,9 @@ const directedForceVis = function (slice, json) {
|
|||
const div = d3.select(slice.selector);
|
||||
const width = slice.width();
|
||||
const height = slice.height() - 25;
|
||||
const linkLength = json.form_data.link_length || 200;
|
||||
const charge = json.form_data.charge || -500;
|
||||
const fd = slice.formData;
|
||||
const linkLength = fd.link_length || 200;
|
||||
const charge = fd.charge || -500;
|
||||
|
||||
const links = json.data;
|
||||
const nodes = {};
|
||||
|
|
|
@ -113,13 +113,12 @@ function filterBox(slice, payload) {
|
|||
d3token.selectAll('*').remove();
|
||||
|
||||
// filter box should ignore the dashboard's filters
|
||||
// TODO FUCK
|
||||
// const url = slice.jsonEndpoint({ extraFilters: false });
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
const filtersChoices = {};
|
||||
// Making sure the ordering of the fields matches the setting in the
|
||||
// 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];
|
||||
});
|
||||
ReactDOM.render(
|
||||
|
|
|
@ -57,7 +57,7 @@ function heatmapVis(slice, payload) {
|
|||
|
||||
slice.container.html('');
|
||||
const matrix = {};
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
|
||||
adjustMargins();
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ function histogram(slice, payload) {
|
|||
.classed('minor', true);
|
||||
};
|
||||
|
||||
const numBins = Number(payload.form_data.link_length) || 10;
|
||||
const numBins = Number(slice.formData.link_length) || 10;
|
||||
div.selectAll('*').remove();
|
||||
draw(payload.data, numBins);
|
||||
}
|
||||
|
|
|
@ -191,7 +191,7 @@ const horizonChart = function () {
|
|||
};
|
||||
|
||||
function horizonViz(slice, payload) {
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
const div = d3.select(slice.selector);
|
||||
div.selectAll('*').remove();
|
||||
let extent;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
const $ = require('jquery');
|
||||
|
||||
function iframeWidget(slice, payload) {
|
||||
function iframeWidget(slice) {
|
||||
$('#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>');
|
||||
const iframe = slice.container.find('iframe');
|
||||
iframe.css('height', slice.height());
|
||||
|
|
|
@ -96,7 +96,7 @@ function nvd3Vis(slice, payload) {
|
|||
}
|
||||
|
||||
let width = slice.width();
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
|
||||
const barchartWidth = function () {
|
||||
let bars;
|
||||
|
|
|
@ -8,7 +8,7 @@ require('./parallel_coordinates.css');
|
|||
|
||||
function parallelCoordVis(slice, payload) {
|
||||
$('#code').attr('rows', '15');
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
const data = payload.data;
|
||||
|
||||
let cols = fd.metrics;
|
||||
|
|
|
@ -10,7 +10,7 @@ dt(window, $);
|
|||
|
||||
module.exports = function (slice, payload) {
|
||||
const container = slice.container;
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
container.html(payload.data);
|
||||
if (fd.groupby.length === 1) {
|
||||
const height = container.height();
|
||||
|
|
|
@ -341,8 +341,9 @@ function sunburstVis(slice, payload) {
|
|||
});
|
||||
|
||||
let ext;
|
||||
const fd = slice.formData;
|
||||
|
||||
if (rawData.form_data.metric !== rawData.form_data.secondary_metric) {
|
||||
if (fd.metric !== fd.secondary_metric) {
|
||||
colorByCategory = false;
|
||||
ext = d3.extent(nodes, (d) => d.m2 / d.m1);
|
||||
colorScale = d3.scale.linear()
|
||||
|
|
|
@ -16,7 +16,7 @@ function tableVis(slice, payload) {
|
|||
let timestampFormatter;
|
||||
|
||||
const data = payload.data;
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
// Removing metrics (aggregates) that are strings
|
||||
const realMetrics = [];
|
||||
for (const k in data.records[0]) {
|
||||
|
|
|
@ -230,7 +230,7 @@ function treemap(slice, payload) {
|
|||
const width = slice.width();
|
||||
const height = slice.height() / payload.data.length;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,11 +6,12 @@ import { category21 } from '../javascripts/modules/colors';
|
|||
function wordCloudChart(slice, payload) {
|
||||
const chart = d3.select(slice.selector);
|
||||
const data = payload.data;
|
||||
const fd = slice.formData;
|
||||
const range = [
|
||||
payload.form_data.size_from,
|
||||
payload.form_data.size_to,
|
||||
fd.size_from,
|
||||
fd.size_to,
|
||||
];
|
||||
const rotation = payload.form_data.rotation;
|
||||
const rotation = fd.rotation;
|
||||
let fRotation;
|
||||
if (rotation === 'square') {
|
||||
fRotation = () => ~~(Math.random() * 2) * 90;
|
||||
|
|
|
@ -11,7 +11,7 @@ function worldMapChart(slice, payload) {
|
|||
|
||||
container.css('height', slice.height());
|
||||
div.selectAll('*').remove();
|
||||
const fd = payload.form_data;
|
||||
const fd = slice.formData;
|
||||
// Ignore XXX's to get better normalization
|
||||
let data = payload.data.filter((d) => (d.country && d.country !== 'XXX'));
|
||||
|
||||
|
|
|
@ -15,10 +15,8 @@ const config = {
|
|||
'css-theme': APP_DIR + '/javascripts/css-theme.js',
|
||||
common: APP_DIR + '/javascripts/common.js',
|
||||
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'],
|
||||
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'],
|
||||
profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'],
|
||||
},
|
||||
|
|
|
@ -80,12 +80,6 @@ def load_energy():
|
|||
params=textwrap.dedent("""\
|
||||
{
|
||||
"collapsed_fieldsets": "",
|
||||
"datasource_id": "3",
|
||||
"datasource_name": "energy_usage",
|
||||
"datasource_type": "table",
|
||||
"flt_col_0": "source",
|
||||
"flt_eq_0": "",
|
||||
"flt_op_0": "in",
|
||||
"groupby": [
|
||||
"source",
|
||||
"target"
|
||||
|
@ -111,12 +105,6 @@ def load_energy():
|
|||
{
|
||||
"charge": "-500",
|
||||
"collapsed_fieldsets": "",
|
||||
"datasource_id": "1",
|
||||
"datasource_name": "energy_usage",
|
||||
"datasource_type": "table",
|
||||
"flt_col_0": "source",
|
||||
"flt_eq_0": "",
|
||||
"flt_op_0": "in",
|
||||
"groupby": [
|
||||
"source",
|
||||
"target"
|
||||
|
@ -145,12 +133,6 @@ def load_energy():
|
|||
"all_columns_y": "target",
|
||||
"canvas_image_rendering": "pixelated",
|
||||
"collapsed_fieldsets": "",
|
||||
"datasource_id": "1",
|
||||
"datasource_name": "energy_usage",
|
||||
"datasource_type": "table",
|
||||
"flt_col_0": "source",
|
||||
"flt_eq_0": "",
|
||||
"flt_op_0": "in",
|
||||
"having": "",
|
||||
"linear_color_scheme": "blue_white_yellow",
|
||||
"metric": "sum__value",
|
||||
|
@ -202,9 +184,6 @@ def load_world_bank_health_n_pop():
|
|||
defaults = {
|
||||
"compare_lag": "10",
|
||||
"compare_suffix": "o10Y",
|
||||
"datasource_id": "1",
|
||||
"datasource_name": "birth_names",
|
||||
"datasource_type": "table",
|
||||
"limit": "25",
|
||||
"granularity": "year",
|
||||
"groupby": [],
|
||||
|
@ -218,7 +197,7 @@ def load_world_bank_health_n_pop():
|
|||
"country_fieldtype": "cca3",
|
||||
"secondary_metric": "sum__SP_POP_TOTL",
|
||||
"entity": "country_code",
|
||||
"show_bubbles": "y",
|
||||
"show_bubbles": True,
|
||||
}
|
||||
|
||||
print("Creating slices")
|
||||
|
@ -287,16 +266,20 @@ def load_world_bank_health_n_pop():
|
|||
since="2011-01-01",
|
||||
until="2011-01-02",
|
||||
series="region",
|
||||
limit="0",
|
||||
limit=0,
|
||||
entity="country_name",
|
||||
x="sum__SP_RUR_TOTL_ZS",
|
||||
y="sum__SP_DYN_LE00_IN",
|
||||
size="sum__SP_POP_TOTL",
|
||||
max_bubble_size="50",
|
||||
flt_col_1="country_code",
|
||||
flt_op_1="not in",
|
||||
flt_eq_1="TCA,MNP,DMA,MHL,MCO,SXM,CYM,TUV,IMY,KNA,ASM,ADO,AMA,PLW",
|
||||
num_period_compare="10",)),
|
||||
filters=[{
|
||||
"col": "country_code",
|
||||
"val": [
|
||||
"TCA", "MNP", "DMA", "MHL", "MCO", "SXM", "CYM",
|
||||
"TUV", "IMY", "KNA", "ASM", "ADO", "AMA", "PLW",
|
||||
],
|
||||
"op": "not in"}],
|
||||
)),
|
||||
Slice(
|
||||
slice_name="Rural Breakdown",
|
||||
viz_type='sunburst',
|
||||
|
@ -596,10 +579,6 @@ def load_birth_names():
|
|||
defaults = {
|
||||
"compare_lag": "10",
|
||||
"compare_suffix": "o10Y",
|
||||
"datasource_id": "1",
|
||||
"datasource_name": "birth_names",
|
||||
"datasource_type": "table",
|
||||
"flt_op_1": "in",
|
||||
"limit": "25",
|
||||
"granularity": "ds",
|
||||
"groupby": [],
|
||||
|
@ -623,8 +602,12 @@ def load_birth_names():
|
|||
params=get_slice_json(
|
||||
defaults,
|
||||
groupby=['name'],
|
||||
flt_col_1='gender',
|
||||
flt_eq_1="girl", row_limit=50)),
|
||||
filters=[{
|
||||
'col': 'gender',
|
||||
'op': 'in',
|
||||
'val': ['girl'],
|
||||
}],
|
||||
row_limit=50)),
|
||||
Slice(
|
||||
slice_name="Boys",
|
||||
viz_type='table',
|
||||
|
@ -633,8 +616,11 @@ def load_birth_names():
|
|||
params=get_slice_json(
|
||||
defaults,
|
||||
groupby=['name'],
|
||||
flt_col_1='gender',
|
||||
flt_eq_1="boy",
|
||||
filters=[{
|
||||
'col': 'gender',
|
||||
'op': 'in',
|
||||
'val': ['boy'],
|
||||
}],
|
||||
row_limit=50)),
|
||||
Slice(
|
||||
slice_name="Participants",
|
||||
|
@ -660,9 +646,14 @@ def load_birth_names():
|
|||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
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'],
|
||||
groupby=['state'], flt_op_1='not in', flt_col_1='state')),
|
||||
groupby=['state'])),
|
||||
Slice(
|
||||
slice_name="Trends",
|
||||
viz_type='line',
|
||||
|
@ -671,7 +662,7 @@ def load_birth_names():
|
|||
params=get_slice_json(
|
||||
defaults,
|
||||
viz_type="line", groupby=['name'],
|
||||
granularity='ds', rich_tooltip='y', show_legend='y')),
|
||||
granularity='ds', rich_tooltip=True, show_legend=True)),
|
||||
Slice(
|
||||
slice_name="Average and Sum Trends",
|
||||
viz_type='dual_line',
|
||||
|
@ -726,7 +717,11 @@ def load_birth_names():
|
|||
params=get_slice_json(
|
||||
defaults,
|
||||
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')),
|
||||
]
|
||||
for slc in slices:
|
||||
|
@ -851,10 +846,6 @@ def load_unicode_test_data():
|
|||
tbl = obj
|
||||
|
||||
slice_data = {
|
||||
"datasource_id": "3",
|
||||
"datasource_name": "unicode_test",
|
||||
"datasource_type": "table",
|
||||
"flt_op_1": "in",
|
||||
"granularity": "date",
|
||||
"groupby": [],
|
||||
"metric": 'sum__value',
|
||||
|
@ -934,13 +925,11 @@ def load_random_time_series_data():
|
|||
tbl = obj
|
||||
|
||||
slice_data = {
|
||||
"datasource_id": "6",
|
||||
"datasource_name": "random_time_series",
|
||||
"datasource_type": "table",
|
||||
"granularity": "day",
|
||||
"row_limit": config.get("ROW_LIMIT"),
|
||||
"since": "1 year ago",
|
||||
"until": "now",
|
||||
"metric": "count",
|
||||
"where": "",
|
||||
"viz_type": "cal_heatmap",
|
||||
"domain_granularity": "month",
|
||||
|
@ -1002,9 +991,6 @@ def load_long_lat_data():
|
|||
tbl = obj
|
||||
|
||||
slice_data = {
|
||||
"datasource_id": "7",
|
||||
"datasource_name": "long_lat",
|
||||
"datasource_type": "table",
|
||||
"granularity": "day",
|
||||
"since": "2014-01-01",
|
||||
"until": "now",
|
||||
|
@ -1084,10 +1070,8 @@ def load_multiformat_time_series_data():
|
|||
print("Creating some slices")
|
||||
for i, col in enumerate(tbl.columns):
|
||||
slice_data = {
|
||||
"metric": 'count',
|
||||
"granularity_sqla": col.column_name,
|
||||
"datasource_id": "8",
|
||||
"datasource_name": "multiformat_time_series",
|
||||
"datasource_type": "table",
|
||||
"granularity": "day",
|
||||
"row_limit": config.get("ROW_LIMIT"),
|
||||
"since": "1 year ago",
|
||||
|
|
1165
superset/forms.py
1165
superset/forms.py
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -4,7 +4,6 @@ from __future__ import division
|
|||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import ast
|
||||
from collections import OrderedDict
|
||||
import functools
|
||||
import json
|
||||
|
@ -13,6 +12,9 @@ import numpy
|
|||
import pickle
|
||||
import re
|
||||
import textwrap
|
||||
from future.standard_library import install_aliases
|
||||
install_aliases()
|
||||
from urllib import parse
|
||||
from copy import deepcopy, copy
|
||||
from datetime import timedelta, datetime, date
|
||||
|
||||
|
@ -24,7 +26,7 @@ from sqlalchemy.engine.url import make_url
|
|||
from sqlalchemy.orm import subqueryload
|
||||
|
||||
import sqlparse
|
||||
from dateutil.parser import parse
|
||||
from dateutil.parser import parse as dparse
|
||||
|
||||
from flask import escape, g, Markup, request
|
||||
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_utils import EncryptedType
|
||||
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
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.viz import viz_types
|
||||
from superset.jinja_context import get_template_processor
|
||||
|
@ -309,34 +310,37 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
|
|||
except Exception as e:
|
||||
logging.exception(e)
|
||||
d['error'] = str(e)
|
||||
d['slice_id'] = self.id
|
||||
d['slice_name'] = self.slice_name
|
||||
d['description'] = self.description
|
||||
d['slice_url'] = self.slice_url
|
||||
d['edit_url'] = self.edit_url
|
||||
d['description_markeddown'] = self.description_markeddown
|
||||
return d
|
||||
return {
|
||||
'datasource': self.datasource_name,
|
||||
'description': self.description,
|
||||
'description_markeddown': self.description_markeddown,
|
||||
'edit_url': self.edit_url,
|
||||
'form_data': self.form_data,
|
||||
'slice_id': self.id,
|
||||
'slice_name': self.slice_name,
|
||||
'slice_url': self.slice_url,
|
||||
}
|
||||
|
||||
@property
|
||||
def json_data(self):
|
||||
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
|
||||
def slice_url(self):
|
||||
"""Defines the url to access the slice"""
|
||||
try:
|
||||
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(
|
||||
return (
|
||||
"/superset/explore/{obj.datasource_type}/"
|
||||
"{obj.datasource_id}/".format(obj=self))
|
||||
return href(slice_params)
|
||||
"{obj.datasource_id}/?form_data={params}".format(
|
||||
obj=self, params=parse.quote(json.dumps(self.form_data))))
|
||||
|
||||
@property
|
||||
def slice_id_url(self):
|
||||
|
@ -364,21 +368,15 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
|
|||
url_params_multidict or self.params.
|
||||
: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['json'] = "false"
|
||||
slice_params['slice_name'] = self.slice_name
|
||||
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[immutable_slice_params.get('viz_type')](
|
||||
return viz_types[slice_params.get('viz_type')](
|
||||
self.datasource,
|
||||
form_data=immutable_slice_params,
|
||||
form_data=slice_params,
|
||||
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)"""
|
||||
|
||||
# Used to do code highlighting when displaying the query in the UI
|
||||
query_language = None
|
||||
|
||||
@property
|
||||
def column_names(self):
|
||||
return sorted([c.column_name for c in self.columns])
|
||||
|
@ -686,33 +687,40 @@ class Queryable(object):
|
|||
else:
|
||||
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
|
||||
def data(self):
|
||||
"""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 = []
|
||||
for s in sorted(self.column_names):
|
||||
order_by_choices.append((json.dumps([s, True]), s + ' [asc]'))
|
||||
order_by_choices.append((json.dumps([s, False]), s + ' [desc]'))
|
||||
|
||||
d = {
|
||||
'id': self.id,
|
||||
'type': self.type,
|
||||
'name': self.name,
|
||||
'metrics_combo': self.metrics_combo,
|
||||
'order_by_choices': order_by_choices,
|
||||
'gb_cols': gb_cols,
|
||||
'all_cols': all_cols,
|
||||
'filterable_cols': filter_cols,
|
||||
'all_cols': utils.choicify(self.column_names),
|
||||
'column_formats': self.column_formats,
|
||||
'edit_url' : self.url,
|
||||
'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':
|
||||
grains = self.database.grains() or []
|
||||
if 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
|
||||
return d
|
||||
|
||||
|
@ -1094,11 +1102,12 @@ class SqlMetric(Model, AuditMixinNullable, ImportMixin):
|
|||
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"""
|
||||
|
||||
type = "table"
|
||||
query_language = 'sql'
|
||||
|
||||
__tablename__ = 'tables'
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
@ -1172,7 +1181,7 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
|
|||
@property
|
||||
def dttm_cols(self):
|
||||
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)
|
||||
return l
|
||||
|
||||
|
@ -1261,8 +1270,9 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
|
|||
con=engine
|
||||
)
|
||||
|
||||
def query( # sqla
|
||||
self, groupby, metrics,
|
||||
def get_query_str( # sqla
|
||||
self, engine, qry_start_dttm,
|
||||
groupby, metrics,
|
||||
granularity,
|
||||
from_dttm, to_dttm,
|
||||
filter=None, # noqa
|
||||
|
@ -1285,7 +1295,6 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
|
|||
|
||||
cols = {col.column_name: col for col in self.columns}
|
||||
metrics_dict = {m.metric_name: m for m in self.metrics}
|
||||
qry_start_dttm = datetime.now()
|
||||
|
||||
if not granularity and is_timeseries:
|
||||
raise Exception(_(
|
||||
|
@ -1374,20 +1383,16 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
|
|||
|
||||
where_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]
|
||||
if op in ('in', 'not in'):
|
||||
split = FilterPattern.split(eq)[1::2]
|
||||
values = [types.strip() for types in split]
|
||||
# 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]
|
||||
splitted = FilterPattern.split(eq)[1::2]
|
||||
values = [types.strip("'").strip('"') for types in splitted]
|
||||
cond = col_obj.sqla_col.in_(values)
|
||||
if op == 'not in':
|
||||
cond = ~cond
|
||||
|
@ -1443,12 +1448,18 @@ class SqlaTable(Model, Queryable, AuditMixinNullable, ImportMixin):
|
|||
|
||||
qry = qry.select_from(tbl)
|
||||
|
||||
engine = self.database.get_sqla_engine()
|
||||
sql = "{}".format(
|
||||
qry.compile(
|
||||
engine, compile_kwargs={"literal_binds": True},),
|
||||
)
|
||||
logging.info(sql)
|
||||
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
|
||||
error_message = None
|
||||
df = None
|
||||
|
@ -1873,11 +1884,12 @@ class DruidMetric(Model, AuditMixinNullable, ImportMixin):
|
|||
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)"""
|
||||
|
||||
type = "druid"
|
||||
query_langtage = "json"
|
||||
|
||||
baselink = "druiddatasourcemodelview"
|
||||
|
||||
|
@ -2045,7 +2057,7 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
if not results:
|
||||
return
|
||||
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,
|
||||
# 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).
|
||||
|
@ -2286,8 +2298,9 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
|
||||
return df
|
||||
|
||||
def query( # druid
|
||||
self, groupby, metrics,
|
||||
def get_query_str( # druid
|
||||
self, client, qry_start_dttm,
|
||||
groupby, metrics,
|
||||
granularity,
|
||||
from_dttm, to_dttm,
|
||||
filter=None, # noqa
|
||||
|
@ -2299,13 +2312,12 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
orderby=None,
|
||||
extras=None, # noqa
|
||||
select=None, # noqa
|
||||
columns=None, ):
|
||||
columns=None, phase=2):
|
||||
"""Runs a query against Druid and returns a dataframe.
|
||||
|
||||
This query interface is common to SqlAlchemy and Druid
|
||||
"""
|
||||
# TODO refactor into using a TBD Query object
|
||||
qry_start_dttm = datetime.now()
|
||||
if not is_timeseries:
|
||||
granularity = 'all'
|
||||
inner_from_dttm = inner_from_dttm or from_dttm
|
||||
|
@ -2401,7 +2413,6 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
if having_filters:
|
||||
qry['having'] = having_filters
|
||||
|
||||
client = self.cluster.get_pydruid_client()
|
||||
orig_filters = filters
|
||||
if len(groupby) == 0:
|
||||
del qry['dimensions']
|
||||
|
@ -2440,6 +2451,8 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
query_str += json.dumps(
|
||||
client.query_builder.last_query.query_dict, indent=2)
|
||||
query_str += "\n"
|
||||
if phase == 1:
|
||||
return query_str
|
||||
query_str += (
|
||||
"//\nPhase 2 (built based on phase one's results)\n")
|
||||
df = client.export_pandas()
|
||||
|
@ -2479,15 +2492,24 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
client.groupby(**qry)
|
||||
query_str += json.dumps(
|
||||
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()
|
||||
|
||||
if df is None or df.size == 0:
|
||||
raise Exception(_("No data was returned."))
|
||||
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 (
|
||||
not is_timeseries and
|
||||
granularity == "all" and
|
||||
query_obj['granularity'] == "all" and
|
||||
DTTM_ALIAS in df.columns):
|
||||
del df[DTTM_ALIAS]
|
||||
|
||||
|
@ -2495,11 +2517,11 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
cols = []
|
||||
if DTTM_ALIAS in df.columns:
|
||||
cols += [DTTM_ALIAS]
|
||||
cols += [col for col in groupby if col in df.columns]
|
||||
cols += [col for col in metrics if col in df.columns]
|
||||
cols += [col for col in query_obj['groupby'] if col in df.columns]
|
||||
cols += [col for col in query_obj['metrics'] if col in df.columns]
|
||||
df = df[cols]
|
||||
|
||||
time_offset = DruidDatasource.time_offset(granularity)
|
||||
time_offset = DruidDatasource.time_offset(query_obj['granularity'])
|
||||
|
||||
def increment_timestamp(ts):
|
||||
dt = utils.parse_human_datetime(ts).replace(
|
||||
|
@ -2516,7 +2538,12 @@ class DruidDatasource(Model, AuditMixinNullable, Queryable, ImportMixin):
|
|||
@staticmethod
|
||||
def get_filters(raw_filters):
|
||||
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
|
||||
if op == '==':
|
||||
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
|
||||
if op in ['==', '>', '<']:
|
||||
cond = self._get_having_obj(col, op, eq)
|
||||
|
|
|
@ -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">×</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">×</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 %}
|
|
@ -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>
|
||||
|
|
@ -529,8 +529,6 @@ def get_email_address_list(address_string):
|
|||
return address_string
|
||||
|
||||
|
||||
# Forked from the flask_appbuilder.security.decorators
|
||||
# TODO(bkyryliuk): contribute it back to FAB
|
||||
def has_access(f):
|
||||
"""
|
||||
Use this decorator to enable granular security permissions to your
|
||||
|
@ -538,6 +536,9 @@ def has_access(f):
|
|||
associated to users.
|
||||
|
||||
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'):
|
||||
permission_str = f._permission_name
|
||||
|
@ -559,3 +560,8 @@ def has_access(f):
|
|||
next=request.path))
|
||||
f._permission_name = permission_str
|
||||
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]
|
||||
|
|
|
@ -18,7 +18,7 @@ import functools
|
|||
import sqlalchemy as sqla
|
||||
|
||||
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.actions import action
|
||||
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 werkzeug.routing import BaseConverter
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
import superset
|
||||
from superset import (
|
||||
|
@ -183,15 +182,17 @@ def get_error_msg():
|
|||
return error_msg
|
||||
|
||||
|
||||
def json_error_response(msg, status=None):
|
||||
def json_error_response(msg, status=None, stacktrace=None):
|
||||
data = {'error': msg}
|
||||
if stacktrace:
|
||||
data['stacktrace'] = stacktrace
|
||||
status = status if status else 500
|
||||
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):
|
||||
status = status if status else 200
|
||||
def json_success(json_msg, status=200):
|
||||
return Response(json_msg, status=status, mimetype="application/json")
|
||||
|
||||
|
||||
|
@ -209,6 +210,9 @@ def api(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):
|
||||
"""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)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
raise ValidationError("json isn't valid")
|
||||
raise Exception("json isn't valid")
|
||||
|
||||
|
||||
def generate_download_headers(extension):
|
||||
|
@ -1262,12 +1266,25 @@ class Superset(BaseSupersetView):
|
|||
role = sm.find_role(role_name)
|
||||
role.user = existing_users
|
||||
sm.get_session.commit()
|
||||
return Response(json.dumps({
|
||||
return self.json_response({
|
||||
'role': role_name,
|
||||
'# missing users': len(missing_users),
|
||||
'# granted': len(existing_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
|
||||
@expose("/override_role_permissions/", methods=['POST'])
|
||||
|
@ -1317,10 +1334,10 @@ class Superset(BaseSupersetView):
|
|||
role.permissions.append(view_menu_perm)
|
||||
granted_perms.append(view_menu_perm.view_menu.name)
|
||||
db.session.commit()
|
||||
return Response(json.dumps({
|
||||
return self.json_response({
|
||||
'granted': granted_perms,
|
||||
'requested': list(db_ds_names)
|
||||
}), status=201)
|
||||
}, status=201)
|
||||
|
||||
@log_this
|
||||
@has_access
|
||||
|
@ -1446,6 +1463,20 @@ class Superset(BaseSupersetView):
|
|||
session.commit()
|
||||
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(
|
||||
self,
|
||||
slice_id=None,
|
||||
|
@ -1453,21 +1484,38 @@ class Superset(BaseSupersetView):
|
|||
datasource_type=None,
|
||||
datasource_id=None):
|
||||
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()
|
||||
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_type, datasource_id, db.session)
|
||||
viz_obj = viz.viz_types[viz_type](
|
||||
datasource, request.args if request.args else args)
|
||||
datasource,
|
||||
form_data=form_data,
|
||||
)
|
||||
return viz_obj
|
||||
|
||||
@has_access
|
||||
@expose("/slice/<slice_id>/")
|
||||
def slice(self, 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
|
||||
@has_access_api
|
||||
|
@ -1480,21 +1528,54 @@ class Superset(BaseSupersetView):
|
|||
args=request.args)
|
||||
except Exception as 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):
|
||||
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 = {}
|
||||
try:
|
||||
payload = viz_obj.get_payload()
|
||||
except Exception as e:
|
||||
logging.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'])
|
||||
@log_this
|
||||
|
@ -1523,35 +1604,31 @@ class Superset(BaseSupersetView):
|
|||
@has_access
|
||||
@expose("/explore/<datasource_type>/<datasource_id>/")
|
||||
def explore(self, datasource_type, datasource_id):
|
||||
viz_type = request.args.get("viz_type")
|
||||
slice_id = request.args.get('slice_id')
|
||||
slc = None
|
||||
form_data = self.get_form_data()
|
||||
|
||||
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
|
||||
|
||||
slc = None
|
||||
if slice_id:
|
||||
slc = db.session.query(models.Slice).filter_by(id=slice_id).first()
|
||||
|
||||
error_redirect = '/slicemodelview/list/'
|
||||
datasource_class = SourceRegistry.sources[datasource_type]
|
||||
datasources = db.session.query(datasource_class).all()
|
||||
datasources = sorted(datasources, key=lambda ds: ds.full_name)
|
||||
datasource = (
|
||||
db.session.query(SourceRegistry.sources[datasource_type])
|
||||
.filter_by(id=datasource_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
try:
|
||||
viz_obj = self.get_viz(
|
||||
datasource_type=datasource_type,
|
||||
datasource_id=datasource_id,
|
||||
args=request.args)
|
||||
except Exception as e:
|
||||
flash('{}'.format(e), "alert")
|
||||
if not datasource:
|
||||
flash(DATASOURCE_MISSING_ERR, "danger")
|
||||
return redirect(error_redirect)
|
||||
|
||||
if not viz_obj.datasource:
|
||||
flash(DATASOURCE_MISSING_ERR, "alert")
|
||||
return redirect(error_redirect)
|
||||
|
||||
if not self.datasource_access(viz_obj.datasource):
|
||||
if not self.datasource_access(datasource):
|
||||
flash(
|
||||
__(get_datasource_access_error_msg(viz_obj.datasource.name)),
|
||||
__(get_datasource_access_error_msg(datasource.name)),
|
||||
"danger")
|
||||
return redirect(
|
||||
'superset/request_access/?'
|
||||
|
@ -1559,65 +1636,49 @@ class Superset(BaseSupersetView):
|
|||
'datasource_id={datasource_id}&'
|
||||
''.format(**locals()))
|
||||
|
||||
if not viz_type and viz_obj.datasource.default_endpoint:
|
||||
return redirect(viz_obj.datasource.default_endpoint)
|
||||
if not viz_type and datasource.default_endpoint:
|
||||
return redirect(datasource.default_endpoint)
|
||||
|
||||
# slc perms
|
||||
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')
|
||||
|
||||
# handle save or overwrite
|
||||
action = request.args.get('action')
|
||||
if action in ('saveas', 'overwrite'):
|
||||
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
|
||||
# and set flag `is_in_explore_v2_beta`
|
||||
is_in_explore_v2_beta = sm.find_role('explore-v2-beta') in get_user_roles()
|
||||
|
||||
# handle different endpoints
|
||||
if request.args.get("csv") == "true":
|
||||
payload = viz_obj.get_csv()
|
||||
return Response(
|
||||
payload,
|
||||
status=200,
|
||||
headers=generate_download_headers("csv"),
|
||||
mimetype="application/csv")
|
||||
elif request.args.get("standalone") == "true":
|
||||
return self.render_template("superset/standalone.html", viz=viz_obj, standalone_mode=True)
|
||||
elif request.args.get("V2") == "true" or is_in_explore_v2_beta:
|
||||
# bootstrap data for explore V2
|
||||
bootstrap_data = {
|
||||
"can_add": slice_add_perm,
|
||||
"can_download": slice_download_perm,
|
||||
"can_edit": slice_edit_perm,
|
||||
# TODO: separate endpoint for fetching datasources
|
||||
"datasources": [(d.id, d.full_name) for d in datasources],
|
||||
"datasource_id": datasource_id,
|
||||
"datasource_name": viz_obj.datasource.name,
|
||||
"datasource_type": datasource_type,
|
||||
"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 ''
|
||||
)
|
||||
form_data['datasource'] = str(datasource_id) + '__' + datasource_type
|
||||
standalone = request.args.get("standalone") == "true"
|
||||
bootstrap_data = {
|
||||
"can_add": slice_add_perm,
|
||||
"can_download": slice_download_perm,
|
||||
"can_overwrite": slice_overwrite_perm,
|
||||
"datasource": datasource.data,
|
||||
# TODO: separate endpoint for fetching datasources
|
||||
"form_data": form_data,
|
||||
"datasource_id": datasource_id,
|
||||
"datasource_type": datasource_type,
|
||||
"slice": slc.data if slc else None,
|
||||
"standalone": standalone,
|
||||
"user_id": user_id,
|
||||
"forced_height": request.args.get('height'),
|
||||
}
|
||||
table_name = datasource.table_name \
|
||||
if datasource_type == 'table' \
|
||||
else datasource.datasource_name
|
||||
return self.render_template(
|
||||
"superset/explorev2.html",
|
||||
bootstrap_data=json.dumps(bootstrap_data),
|
||||
slice=slc,
|
||||
standalone_mode=standalone,
|
||||
table_name=table_name)
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
|
@ -1662,44 +1723,28 @@ class Superset(BaseSupersetView):
|
|||
return json_success(obj.get_values_for_column(column))
|
||||
|
||||
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"""
|
||||
slice_name = args.get('slice_name')
|
||||
action = args.get('action')
|
||||
|
||||
# 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')
|
||||
form_data = self.get_form_data()
|
||||
|
||||
if action in ('saveas'):
|
||||
if 'slice_id' in d:
|
||||
d.pop('slice_id') # don't save old slice_id
|
||||
if 'slice_id' in form_data:
|
||||
form_data.pop('slice_id') # don't save old slice_id
|
||||
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.viz_type = args.get('viz_type')
|
||||
slc.viz_type = form_data['viz_type']
|
||||
slc.datasource_type = datasource_type
|
||||
slc.datasource_id = datasource_id
|
||||
slc.slice_name = slice_name
|
||||
|
||||
if action in ('saveas') and slice_add_perm:
|
||||
self.save_slice(slc)
|
||||
elif action == 'overwrite' and slice_edit_perm:
|
||||
elif action == 'overwrite' and slice_overwrite_perm:
|
||||
self.overwrite_slice(slc)
|
||||
|
||||
# Adding slice to a dashboard if requested
|
||||
|
@ -1731,13 +1776,9 @@ class Superset(BaseSupersetView):
|
|||
db.session.commit()
|
||||
|
||||
if request.args.get('goto_dash') == 'true':
|
||||
if request.args.get('V2') == 'true':
|
||||
return dash.url
|
||||
return redirect(dash.url)
|
||||
return dash.url
|
||||
else:
|
||||
if request.args.get('V2') == 'true':
|
||||
return slc.slice_url
|
||||
return redirect(slc.slice_url)
|
||||
return slc.slice_url
|
||||
|
||||
def save_slice(self, slc):
|
||||
session = db.session()
|
||||
|
@ -1747,15 +1788,11 @@ class Superset(BaseSupersetView):
|
|||
flash(msg, "info")
|
||||
|
||||
def overwrite_slice(self, slc):
|
||||
can_update = check_ownership(slc, raise_if_false=False)
|
||||
if not can_update:
|
||||
flash("You cannot overwrite [{}]".format(slc), "danger")
|
||||
else:
|
||||
session = db.session()
|
||||
session.merge(slc)
|
||||
session.commit()
|
||||
msg = "Slice [{}] has been overwritten".format(slc.slice_name)
|
||||
flash(msg, "info")
|
||||
session = db.session()
|
||||
session.merge(slc)
|
||||
session.commit()
|
||||
msg = "Slice [{}] has been overwritten".format(slc.slice_name)
|
||||
flash(msg, "info")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
|
@ -2603,11 +2640,12 @@ class Superset(BaseSupersetView):
|
|||
@expose("/fetch_datasource_metadata")
|
||||
@log_this
|
||||
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 = (
|
||||
db.session.query(datasource_class)
|
||||
.filter_by(id=request.args.get('datasource_id'))
|
||||
.filter_by(id=int(datasource_id))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
|
1021
superset/viz.py
1021
superset/viz.py
File diff suppressed because it is too large
Load Diff
|
@ -78,14 +78,22 @@ class CoreTests(SupersetTestCase):
|
|||
self.login(username='admin')
|
||||
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
|
||||
|
||||
def test_slice_csv_endpoint(self):
|
||||
self.login(username='admin')
|
||||
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
|
||||
|
||||
def test_admin_only_permissions(self):
|
||||
|
@ -122,24 +130,55 @@ class CoreTests(SupersetTestCase):
|
|||
db.session.commit()
|
||||
copy_name = "Test Sankey Save"
|
||||
tbl_id = self.table_ids.get('energy_usage')
|
||||
new_slice_name = "Test Sankey Overwirte"
|
||||
|
||||
url = (
|
||||
"/superset/explore/table/{}/?viz_type=sankey&groupby=source&"
|
||||
"groupby=target&metric=sum__value&row_limit=5000&where=&having=&"
|
||||
"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")
|
||||
"/superset/explore/table/{}/?slice_name={}&"
|
||||
"action={}&datasource_name=energy_usage&form_data={}")
|
||||
|
||||
# Changing name
|
||||
resp = self.get_resp(url.format(tbl_id, slice_id, copy_name, 'save'))
|
||||
assert copy_name in resp
|
||||
form_data = {
|
||||
'viz_type': 'sankey',
|
||||
'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
|
||||
resp = self.get_resp(url.format(tbl_id, slice_id, slice_name, 'save'))
|
||||
assert slice_name in resp
|
||||
form_data = {
|
||||
'viz_type': 'sankey',
|
||||
'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):
|
||||
self.login(username='admin')
|
||||
|
@ -168,8 +207,6 @@ class CoreTests(SupersetTestCase):
|
|||
for slc in db.session.query(Slc).all():
|
||||
urls += [
|
||||
(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),
|
||||
]
|
||||
for name, method, url in urls:
|
||||
|
@ -544,8 +581,7 @@ class CoreTests(SupersetTestCase):
|
|||
self.login(username='admin')
|
||||
url = (
|
||||
'/superset/fetch_datasource_metadata?'
|
||||
'datasource_type=table&'
|
||||
'datasource_id=1'
|
||||
+ 'datasourceKey=1__table'
|
||||
)
|
||||
resp = self.get_json_resp(url)
|
||||
keys = [
|
||||
|
|
|
@ -116,30 +116,44 @@ class DruidTests(SupersetTestCase):
|
|||
|
||||
resp = self.get_resp('/superset/explore/druid/{}/'.format(
|
||||
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
|
||||
url = (
|
||||
'/superset/explore_json/druid/{}/?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&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))
|
||||
'/superset/explore_json/druid/{}/?form_data={}'.format(
|
||||
datasource_id, json.dumps(form_data))
|
||||
)
|
||||
resp = self.get_json_resp(url)
|
||||
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
|
||||
url = (
|
||||
'/superset/explore_json/druid/{}/?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&'
|
||||
'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))
|
||||
'/superset/explore_json/druid/{}/?form_data={}'.format(
|
||||
datasource_id, json.dumps(form_data))
|
||||
)
|
||||
resp = self.get_json_resp(url)
|
||||
self.assertEqual("Canada", resp['data']['records'][0]['dim1'])
|
||||
|
||||
|
|
Loading…
Reference in New Issue