diff --git a/superset/assets/backendSync.json b/superset/assets/backendSync.json
index ba91b47cde46..631b53ae90ce 100644
--- a/superset/assets/backendSync.json
+++ b/superset/assets/backendSync.json
@@ -2059,6 +2059,18 @@
"default": "linear",
"description": "Line interpolation as defined by d3.js"
},
+ "overlays": {
+ "type": "SelectControl",
+ "multi": true,
+ "label": "Overlays",
+ "default": []
+ },
+ "offset_overlays": {
+ "type": "CheckboxControl",
+ "label": "Auto Offset Overlays",
+ "default": false,
+ "description": "Auto offset overlay to match the time frame of the current config."
+ },
"pie_label_type": {
"type": "SelectControl",
"label": "Label Type",
diff --git a/superset/assets/javascripts/explore/components/ExploreActionButtons.jsx b/superset/assets/javascripts/explore/components/ExploreActionButtons.jsx
index 57f2dfd744c4..2604c81e3b29 100644
--- a/superset/assets/javascripts/explore/components/ExploreActionButtons.jsx
+++ b/superset/assets/javascripts/explore/components/ExploreActionButtons.jsx
@@ -5,6 +5,8 @@ import URLShortLinkButton from './URLShortLinkButton';
import EmbedCodeButton from './EmbedCodeButton';
import DisplayQueryButton from './DisplayQueryButton';
import { t } from '../../locales';
+import { getSwivelUrl } from '../exploreUtils';
+import { isSupportedBySwivel } from '../../swivel/formDataUtils/convertToFormData';
const propTypes = {
canDownload: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]).isRequired,
@@ -22,6 +24,19 @@ export default function ExploreActionButtons({
if (slice) {
return (
+ {
+ queryResponse &&
+ isSupportedBySwivel(queryResponse.form_data) &&
+
+ Open in Swivel
+
+ }
diff --git a/superset/assets/javascripts/explore/exploreUtils.js b/superset/assets/javascripts/explore/exploreUtils.js
index 8a01745d9a39..476e5142f5d8 100644
--- a/superset/assets/javascripts/explore/exploreUtils.js
+++ b/superset/assets/javascripts/explore/exploreUtils.js
@@ -1,5 +1,16 @@
/* eslint camelcase: 0 */
import URI from 'urijs';
+import { compressToBase64 } from 'lz-string';
+
+export function trimFormData(formData) {
+ const cleaned = { ...formData };
+ Object.entries(formData).forEach(([k, v]) => {
+ if (v === null || v === undefined) {
+ delete cleaned[k];
+ }
+ });
+ return cleaned;
+}
export function getChartKey(explore) {
const slice = explore.slice;
@@ -14,8 +25,7 @@ export function getAnnotationJsonUrl(slice_id, form_data, isNative) {
const endpoint = isNative ? 'annotation_json' : 'slice_json';
return uri.pathname(`/superset/${endpoint}/${slice_id}`)
.search({
- form_data: JSON.stringify(form_data,
- (key, value) => value === null ? undefined : value),
+ form_data: JSON.stringify(trimFormData(form_data)),
}).toString();
}
@@ -43,7 +53,7 @@ export function getExploreUrl(form_data, endpointType = 'base', force = false,
// Building the querystring (search) part of the URI
const search = uri.search(true);
- search.form_data = JSON.stringify(form_data);
+ search.form_data = JSON.stringify(trimFormData(form_data));
if (force) {
search.force = 'true';
}
@@ -67,3 +77,21 @@ export function getExploreUrl(form_data, endpointType = 'base', force = false,
uri = uri.search(search).directory(directory);
return uri.toString();
}
+
+export function getSwivelUrl(formData, lzCompress) {
+ if (!formData || !formData.datasource) {
+ return null;
+ }
+
+ const uri = URI(window.location.search);
+
+ // Building the query
+ if (lzCompress) {
+ return uri.pathname('/swivel')
+ .search({ lz_form_data: compressToBase64(JSON.stringify(trimFormData(formData))) })
+ .toString();
+ }
+ return uri.pathname('/swivel')
+ .search({ form_data: JSON.stringify(trimFormData(formData)) })
+ .toString();
+}
diff --git a/superset/assets/javascripts/explore/reducers/exploreReducer.js b/superset/assets/javascripts/explore/reducers/exploreReducer.js
index 7b55748800ff..afc5e6ed9d68 100644
--- a/superset/assets/javascripts/explore/reducers/exploreReducer.js
+++ b/superset/assets/javascripts/explore/reducers/exploreReducer.js
@@ -79,3 +79,4 @@ export default function exploreReducer(state = {}, action) {
}
return state;
}
+
diff --git a/superset/assets/javascripts/swivel/ColumnTypes.jsx b/superset/assets/javascripts/swivel/ColumnTypes.jsx
new file mode 100644
index 000000000000..2324d7df752a
--- /dev/null
+++ b/superset/assets/javascripts/swivel/ColumnTypes.jsx
@@ -0,0 +1,5 @@
+export default {
+ TIMESTAMP: 'TIMESTAMP',
+ NUMERIC: 'NUMERIC',
+ STRING: 'NVARCHAR',
+};
diff --git a/superset/assets/javascripts/swivel/ContainerTypes.jsx b/superset/assets/javascripts/swivel/ContainerTypes.jsx
new file mode 100644
index 000000000000..b7138f7fff45
--- /dev/null
+++ b/superset/assets/javascripts/swivel/ContainerTypes.jsx
@@ -0,0 +1,4 @@
+export default {
+ SPLIT: 'SPLIT',
+ FILTER: 'FILTER',
+};
diff --git a/superset/assets/javascripts/swivel/FilterTypes.jsx b/superset/assets/javascripts/swivel/FilterTypes.jsx
new file mode 100644
index 000000000000..0cf0fb3303ae
--- /dev/null
+++ b/superset/assets/javascripts/swivel/FilterTypes.jsx
@@ -0,0 +1,5 @@
+export default {
+ INTERVAL: 'INTERVAL',
+ SELECT: 'SELECT',
+ UNBOUND: 'UNBOUND',
+};
diff --git a/superset/assets/javascripts/swivel/ItemTypes.jsx b/superset/assets/javascripts/swivel/ItemTypes.jsx
new file mode 100644
index 000000000000..f7e8b4950df6
--- /dev/null
+++ b/superset/assets/javascripts/swivel/ItemTypes.jsx
@@ -0,0 +1,3 @@
+export default {
+ DIMENSION: 'dimension',
+};
diff --git a/superset/assets/javascripts/swivel/SessionManager.js b/superset/assets/javascripts/swivel/SessionManager.js
new file mode 100644
index 000000000000..889aa7ecfd22
--- /dev/null
+++ b/superset/assets/javascripts/swivel/SessionManager.js
@@ -0,0 +1,98 @@
+import moment from 'moment';
+import uuidv4 from 'uuid/v4';
+
+import { LOCAL_STORAGE_SESSIONS_KEY,
+ LOCAL_STORAGE_KEY_PREFIX, MAX_NUM_SESSIONS } from './constants';
+
+export function getSessions() {
+ let sessions = JSON.parse(localStorage.getItem(LOCAL_STORAGE_SESSIONS_KEY));
+ if (!Array.isArray(sessions)) {
+ sessions = [];
+ } else {
+ sessions = sessions.filter(x => !!x && !!x.ts).sort((a, b) => b.ts - a.ts);
+ }
+ return sessions;
+}
+
+function saveSessions(sessions) {
+ localStorage.setItem(LOCAL_STORAGE_SESSIONS_KEY, JSON.stringify(sessions));
+ return sessions;
+}
+
+function cleanup(sessions) {
+ // Cleanup old sessions
+ Object.keys(localStorage)
+ .filter(x => x.startsWith(LOCAL_STORAGE_KEY_PREFIX) &&
+ !sessions.find(s => `${LOCAL_STORAGE_KEY_PREFIX}${s.id}` === x))
+ .forEach(x => localStorage.removeItem(x));
+ saveSessions(sessions);
+}
+
+export function deleteSessions() {
+ cleanup([]);
+}
+
+export function deleteSession(id) {
+ const sessions = getSessions();
+ cleanup(sessions.filter(x => x.id !== id));
+}
+
+export function createNewSession(name, id) {
+ let sessions = getSessions();
+ if (sessions.length > MAX_NUM_SESSIONS) {
+ localStorage.removeItem(sessions[sessions.length - 1]);
+ sessions.pop();
+ }
+ const newSession = { ts: moment.now(), id: id || uuidv4(), name };
+ sessions = [newSession, ...sessions];
+ cleanup(sessions);
+ return newSession;
+}
+
+export function updateSession(id, name) {
+ const sessions = getSessions();
+ let session = sessions.find(x => x.id === id);
+ if (session) {
+ session.name = name || session.name;
+ session.ts = moment.now();
+ saveSessions(sessions);
+ } else {
+ session = createNewSession(name, id);
+ }
+ window.document.title = `Swivel - ${session.name} (${session.id.substring(0, 7)})`;
+}
+
+export function getSessionKey(bootstrapData) {
+ let swivelSession = null;
+ const sessions = getSessions();
+
+ const createNew = bootstrapData.new ||
+ bootstrapData.reset ||
+ bootstrapData.lz_form_data ||
+ bootstrapData.form_data;
+
+ const now = moment.now();
+ // Read the current session from Local Storage
+ if (bootstrapData.session &&
+ sessions.find(x => x.id === bootstrapData.session)) {
+ // Session was passed in with bootstrapData
+ const s = sessions.find(x => x.id === bootstrapData.session);
+ s.ts = now;
+ swivelSession = s.id;
+ } else if (sessions.length && !createNew) {
+ // Get the most recent session.
+ const s = sessions[0];
+ swivelSession = s.id;
+ s.ts = now;
+ }
+
+ // Create a new Session
+ if (!swivelSession || createNew) {
+ swivelSession = createNewSession('').id;
+ } else {
+ saveSessions(sessions);
+ }
+
+ window.history.pushState('', '', `${location.pathname}?session=${swivelSession}`);
+ return swivelSession;
+}
diff --git a/superset/assets/javascripts/swivel/actions/globalActions.js b/superset/assets/javascripts/swivel/actions/globalActions.js
new file mode 100644
index 000000000000..6cd313ea5cdc
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/globalActions.js
@@ -0,0 +1,51 @@
+// Aborts the current query
+export const ABORT = 'ABORT';
+export function abort() {
+ return { type: ABORT };
+}
+
+export const RESET = 'RESET';
+export const CLEAR_HISTORY = 'CLEAR_HISTORY';
+export function reset(clearHistory) {
+ if (clearHistory) {
+ return dispatch =>
+ // We need the sandwich to make sure there is enough space in the
+ // local storage to RESET
+ Promise.resolve(dispatch({ type: ABORT }))
+ .then(() => dispatch({ type: CLEAR_HISTORY }))
+ .then(() => dispatch({ type: RESET }))
+ .then(() => dispatch({ type: CLEAR_HISTORY }));
+ }
+ return dispatch =>
+ Promise.resolve(dispatch({ type: ABORT }))
+ .then(() => dispatch({ type: RESET }));
+}
+
+// This controls whether a query should be run
+export const SET_RUN = 'SET_RUN';
+export function setRun(run) {
+ return { type: SET_RUN, run };
+}
+
+// This controls if a query should automatically run if the query settings change
+export const SET_AUTO_RUN = 'SET_AUTO_RUN';
+export function setAutoRun(autoRun) {
+ return { type: SET_AUTO_RUN, autoRun };
+}
+
+// This indicates if a query is currently running
+export const SET_IS_RUNNING = 'SET_IS_RUNNING';
+export function setIsRunning(isRunning, queryRequest) {
+ return { type: SET_IS_RUNNING, isRunning, queryRequest };
+}
+
+export const SET_ERROR = 'SET_ERROR';
+export function setError(error) {
+ return { type: SET_ERROR, error };
+}
+
+export const UPDATE_FORM_DATA = 'UPDATE_FORM_DATA';
+export const IMPORT_FORM_DATA = 'IMPORT_FORM_DATA';
+export function importFormData(formData, refData) {
+ return { type: IMPORT_FORM_DATA, formData, refData };
+}
diff --git a/superset/assets/javascripts/swivel/actions/keyBindingsActions.js b/superset/assets/javascripts/swivel/actions/keyBindingsActions.js
new file mode 100644
index 000000000000..cb808213ea31
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/keyBindingsActions.js
@@ -0,0 +1,9 @@
+export const SEARCH_COLUMNS = 'SEARCH_COLUMNS';
+export function searchColumns() {
+ return { type: SEARCH_COLUMNS };
+}
+
+export const SEARCH_METRICS = 'SEARCH_METRICS';
+export function searchMetrics() {
+ return { type: SEARCH_METRICS };
+}
diff --git a/superset/assets/javascripts/swivel/actions/querySettingsActions.js b/superset/assets/javascripts/swivel/actions/querySettingsActions.js
new file mode 100644
index 000000000000..6b4fdc0e78d8
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/querySettingsActions.js
@@ -0,0 +1,105 @@
+import { convertQuerySettingsToFormData } from '../formDataUtils/convertToFormData';
+
+import { fetchDatasources, fetchDatasourceMetadata } from './refDataActions';
+import { UPDATE_FORM_DATA, setError, setAutoRun, importFormData } from './globalActions';
+import { runQuery } from './vizDataActions';
+
+export const SET_DEFAULTS = 'SET_DEFAULTS';
+export function setDefaults(refData) {
+ return { type: SET_DEFAULTS, refData };
+}
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(uid, init = true) {
+ return (dispatch, getState) => {
+ if (getState().settings.future.length === 0 &&
+ getState().settings.present.query.datasource === uid &&
+ getState().refData.columns.length) {
+ return Promise.resolve();
+ }
+ return dispatch(fetchDatasourceMetadata(uid))
+ .then(() => dispatch({
+ type: SET_DATASOURCE,
+ uid,
+ name: (getState()
+ .refData
+ .datasources.find(x => x.uid === uid) || {}).name,
+ }))
+ .then(() => init ? dispatch(
+ setDefaults(getState().refData)) : Promise.resolve());
+ };
+}
+
+export const BOOTSTRAP = 'BOOTSTRAP';
+export function bootstrap(formData) {
+ return (dispatch, getState) =>
+ dispatch(fetchDatasources()).then(() => {
+ const datasource = getState().settings.present.query.datasource;
+ if (formData.datasource) {
+ return Promise.resolve(dispatch(setAutoRun(false)))
+ .then(() => dispatch(setDatasource(formData.datasource, false)))
+ .then(() => dispatch(importFormData(formData, getState().refData)))
+ .then(() => dispatch(setAutoRun(true)));
+ } else if (datasource) {
+ return dispatch(setDatasource(datasource, false));
+ }
+ return Promise.resolve();
+ });
+}
+
+export function updateFormDataAndRunQuery(settings) {
+ return (dispatch) => {
+ const formData = convertQuerySettingsToFormData(settings);
+ return Promise.resolve(
+ dispatch({ type: UPDATE_FORM_DATA, formData, wipeData: true }))
+ .then(() => dispatch(setError(formData.error)))
+ .then(() => dispatch(runQuery()));
+ };
+}
+
+export const TOGGLE_METRIC = 'TOGGLE_METRIC';
+export function toggleMetric(metric) {
+ return { type: TOGGLE_METRIC, metric };
+}
+
+export const ADD_FILTER = 'ADD_FILTER';
+export function addFilter(filter) {
+ return { type: ADD_FILTER, filter };
+}
+
+export const CONFIGURE_FILTER = 'CONFIGURE_FILTER';
+export function configureFilter(filter) {
+ return { type: CONFIGURE_FILTER, filter };
+}
+
+export const REMOVE_FILTER = 'REMOVE_FILTER';
+export function removeFilter(filter) {
+ return { type: REMOVE_FILTER, filter };
+}
+
+export const ADD_SPLIT = 'ADD_SPLIT';
+export function addSplit(split) {
+ return { type: ADD_SPLIT, split };
+}
+
+export const CONFIGURE_SPLIT = 'CONFIGURE_SPLIT';
+export function configureSplit(split) {
+ return { type: CONFIGURE_SPLIT, split };
+}
+
+export const REMOVE_SPLIT = 'REMOVE_SPLIT';
+export function removeSplit(split) {
+ return { type: REMOVE_SPLIT, split };
+}
+
+export const CHANGE_INTERVAL = 'CHANGE_INTERVAL';
+export function changeInterval(intervalStart, intervalEnd) {
+ return { type: CHANGE_INTERVAL, intervalStart, intervalEnd };
+}
+
+// TODO need to move those to the vizSettings
+export const SET_VIZTYPE = 'SET_VIZTYPE';
+export function setVizType(vizType) {
+ return { type: SET_VIZTYPE, vizType };
+}
+
diff --git a/superset/assets/javascripts/swivel/actions/refDataActions.js b/superset/assets/javascripts/swivel/actions/refDataActions.js
new file mode 100644
index 000000000000..c3fb0b8e6ac9
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/refDataActions.js
@@ -0,0 +1,91 @@
+import ColumnTypes from '../ColumnTypes';
+
+export const SET_COLUMNS = 'SET_COLUMNS';
+export function setColumns(columns) {
+ return { type: SET_COLUMNS, columns };
+}
+
+export const SET_DATASOURCES = 'SET_DATASOURCES';
+export function setDatasources(datasources) {
+ return { type: SET_DATASOURCES, datasources };
+}
+
+export const SET_METRICS = 'SET_METRICS';
+export function setMetrics(metrics) {
+ return { type: SET_METRICS, metrics };
+}
+
+export const SET_TIME_GRAINS = 'SET_TIME_GRAINS';
+export function setTimeGrains(timeGrains) {
+ return { type: SET_TIME_GRAINS, timeGrains };
+}
+
+export function fetchDatasources() {
+ return dispatch => fetch('/superset/datasources/', {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'same-origin',
+ }).then(response => response.json()).then((data) => {
+ const datasources = data.map(x => ({
+ uid: x.uid,
+ name: x.name,
+ type: x.type,
+ id: x.id }));
+ return datasources;
+ }).then(datasources => dispatch(setDatasources(datasources)));
+}
+
+export function fetchDatasourceMetadata(uid) {
+ return function (dispatch) {
+ if (!uid) {
+ return Promise.resolve();
+ }
+ const url = `/swivel/fetch_datasource_metadata?uid=${uid}`;
+ return fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'same-origin',
+ }).then(response => response.json()).then((data) => {
+ const columns = data.columns
+ .filter(x => x.groupable ||
+ x.filterable ||
+ (x.type !== ColumnTypes.NUMERIC)).map(x => ({
+ name: x.name,
+ id: x.id,
+ columnType: x.type,
+ groupable: x.groupable,
+ }));
+ const metrics = data.metrics.map(x => ({
+ name: x.name,
+ id: x.id,
+ format: x.format,
+ }));
+ const timeGrains = data.time_grains;
+
+ // Todo: this is hacky should be done better
+ if (uid.endsWith('druid')) {
+ let timeColumn = columns.find(x =>
+ x.id.toLowerCase() === '__time');
+ if (!timeColumn) {
+ timeColumn = {
+ name: 'Time',
+ id: '__time',
+ };
+ columns.push(timeColumn);
+ }
+ timeColumn.columnType = ColumnTypes.TIMESTAMP;
+ timeColumn.groupable = false;
+ }
+
+ return Promise.all([
+ dispatch(setColumns(columns)),
+ dispatch(setMetrics(metrics)),
+ dispatch(setTimeGrains(timeGrains)),
+ ]);
+ });
+ };
+}
diff --git a/superset/assets/javascripts/swivel/actions/vizDataActions.js b/superset/assets/javascripts/swivel/actions/vizDataActions.js
new file mode 100644
index 000000000000..d3b1ebab42df
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/vizDataActions.js
@@ -0,0 +1,63 @@
+import { setError, setRun, setIsRunning } from './globalActions';
+
+export const SET_DATA = 'SET_DATA';
+export function setData(data) {
+ return { type: SET_DATA, data };
+}
+
+export const SET_OUTDATED = 'SET_OUTDATED';
+export function setOutdated(outdated) {
+ return { type: SET_OUTDATED, outdated };
+}
+
+export const RESET_DATA = 'RESET_DATA';
+export function resetData() {
+ return { type: RESET_DATA };
+}
+
+export function runQuery() {
+ return (dispatch, getState) => {
+ const { vizData, controls, settings } = getState();
+ const payload = vizData.formData;
+ if (!controls.error) {
+ const datasource = settings.present.query.datasource;
+ const [dsId, dsType] = datasource.split('__');
+ const url = `${window.location.origin}/superset/explore_json/${dsType}/${dsId}`;
+ const queryRequest = $.ajax({
+ method: 'POST',
+ url,
+ dataType: 'json',
+ contentType: 'application/json; charset=UTF-8',
+ timeout: 300000, // 5 Min
+ data: payload.toJson() });
+ return Promise.resolve(dispatch(setIsRunning(true, queryRequest)))
+ .then(() => queryRequest)
+ .then(data => Promise.all([
+ dispatch(setData(data.data)),
+ dispatch(setRun(false)),
+ dispatch(setOutdated(false)),
+ dispatch(setIsRunning(false, queryRequest))]))
+ .catch(function (res) {
+ if (res.status === 0) {
+ return Promise.all([
+ dispatch(setRun(false)),
+ dispatch(setOutdated(true)),
+ dispatch(setIsRunning(false, queryRequest))]);
+ }
+ let error;
+ if (res.responseJSON) {
+ error = res.responseJSON.error;
+ } else {
+ error = 'Server error';
+ }
+ return Promise.all([
+ dispatch(setIsRunning(false, queryRequest)),
+ dispatch(setData()),
+ dispatch(setError(error)),
+ dispatch(setOutdated(false)),
+ ]);
+ });
+ }
+ return Promise.resolve(dispatch(setOutdated(false)));
+ };
+}
diff --git a/superset/assets/javascripts/swivel/actions/vizSettingsActions.js b/superset/assets/javascripts/swivel/actions/vizSettingsActions.js
new file mode 100644
index 000000000000..4ff7673b8d1a
--- /dev/null
+++ b/superset/assets/javascripts/swivel/actions/vizSettingsActions.js
@@ -0,0 +1,23 @@
+import { convertVizSettingsToFormData } from '../formDataUtils/convertToFormData';
+import { UPDATE_FORM_DATA } from './globalActions';
+
+export const TOGGLE_SHOW_LEGEND = 'TOGGLE_SHOW_LEGEND';
+export function toggleShowLegend() {
+ return { type: TOGGLE_SHOW_LEGEND };
+}
+
+export const TOGGLE_RICH_TOOLTIP = 'TOGGLE_RICH_TOOLTIP';
+export function toggleRichTooltip() {
+ return { type: TOGGLE_RICH_TOOLTIP };
+}
+
+export function updateFormData(vizSettings) {
+ const formData = convertVizSettingsToFormData(vizSettings);
+ return { type: UPDATE_FORM_DATA, formData };
+}
+
+export const TOGGLE_SEPARATE_CHARTS = 'TOGGLE_SEPARATE_CHARTS';
+export function toggleSeparateCharts() {
+ return { type: TOGGLE_SEPARATE_CHARTS };
+}
+
diff --git a/superset/assets/javascripts/swivel/components/ChartContainer.jsx b/superset/assets/javascripts/swivel/components/ChartContainer.jsx
new file mode 100644
index 000000000000..115adacacdea
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/ChartContainer.jsx
@@ -0,0 +1,187 @@
+import $ from 'jquery';
+import { connect } from 'react-redux';
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+
+import visMap from '../../../visualizations/main';
+
+import { addFilter, removeFilter, changeInterval } from '../actions/querySettingsActions';
+import { getMockedSliceObject } from '../formDataUtils/sliceObject';
+
+
+const propTypes = {
+ containerId: PropTypes.string.isRequired,
+ error: PropTypes.string,
+ outdated: PropTypes.bool,
+ isRunning: PropTypes.bool,
+ formData: PropTypes.object,
+ data: PropTypes.oneOfType(
+ [PropTypes.object, PropTypes.arrayOf(PropTypes.object)]),
+ columns: PropTypes.arrayOf(PropTypes.object),
+ selectedMetrics: PropTypes.object,
+ metrics: PropTypes.arrayOf(PropTypes.object),
+
+ intervalCallback: PropTypes.func,
+ handleAddFilter: PropTypes.func,
+ handleRemoveFilter: PropTypes.func,
+};
+
+/**
+ * This component takes care of rendering one of multiple charts
+ */
+class ChartContainer extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.update = this.update.bind(this);
+ this.getCharts = this.getCharts.bind(this);
+ }
+
+ componentDidMount() { this.update(); }
+ componentDidUpdate(prevProps) {
+ if (prevProps.formData !== this.props.formData ||
+ prevProps.data !== this.props.data) {
+ this.update();
+ }
+ }
+
+ /**
+ * This function splits the data into multiple charts
+ * @param data to be rendered
+ * @param metrics to adapt legend and axis labels
+ * @param formData which defines the visualization configuration
+ */
+ getCharts(data, metrics, separateCharts) {
+ if (!data) {
+ return [];
+ } else if (separateCharts && Array.isArray(data) && metrics && metrics.length > 1) {
+ const separatedData = [];
+ for (const metric of metrics) {
+ let series = data.filter((c) => {
+ if (Array.isArray(c.key)) {
+ return c.key[0] === metric.id;
+ }
+ return c.key === metric.id;
+ });
+ series = series.map(c => ({
+ ...c,
+ }));
+ if (series.length) {
+ separatedData.push(series);
+ }
+ }
+ return separatedData;
+ }
+ return [data];
+ }
+
+ deleteGraphs(containerId) {
+ $(`[id^=${containerId}-]`).toArray()
+ .forEach(x => $(x).children().remove());
+ }
+
+ update() {
+ const { error, data, formData, containerId, selectedMetrics, columns,
+ intervalCallback, handleAddFilter, handleRemoveFilter } = this.props;
+ const metrics = this.props.metrics.filter(x => selectedMetrics[x.id]).sort(x => x.name);
+ if (!error && data && formData.viz_type) {
+ // eslint-disable-next-line camelcase
+ const charts = this.getCharts(data, metrics, formData.separate_charts);
+ if (charts.length > 1) {
+ charts.filter(x => x).forEach((series, i) => {
+ const fd = Object.assign({}, formData);
+ const dataObj = {
+ data: series,
+ intervalCallback,
+ };
+ const container = `${containerId}-${i}`;
+ fd.y_axis_label = metrics[i].name;
+ visMap[formData.viz_type](
+ getMockedSliceObject(container,
+ fd,
+ metrics.concat(columns),
+ metrics.length,
+ handleAddFilter,
+ handleRemoveFilter),
+ dataObj,
+ );
+ });
+ } else if (charts.length && charts[0]) {
+ this.deleteGraphs(containerId);
+ const dataObj = {
+ data: charts[0],
+ intervalCallback,
+ };
+ visMap[formData.viz_type](getMockedSliceObject(`${containerId}-0`,
+ formData,
+ metrics.concat(columns),
+ 1,
+ handleAddFilter,
+ handleRemoveFilter), dataObj);
+ }
+ }
+ }
+
+ render() {
+ const { selectedMetrics, containerId, error, isRunning, formData, outdated } = this.props;
+
+ const chartContainers = [`${containerId}-0`];
+ const num = Object.keys(selectedMetrics).length;
+ if (formData.separate_charts && num > 1) {
+ for (let i = 1; i < num; i++) {
+ chartContainers.push(`${containerId}-${i}`);
+ }
+ }
+
+ if (error) {
+ this.deleteGraphs(containerId);
+ }
+
+ return (
+
+
+ {chartContainers.map((chart, index) =>
+ (
+ {error}
+
),
+ )}
+
+
+
+ );
+ }
+}
+
+ChartContainer.propTypes = propTypes;
+
+const mapStateToProps = state => ({
+ isRunning: state.controls.isRunning,
+ outdated: state.vizData.outdated,
+ error: state.controls.error,
+ data: state.vizData.data,
+ formData: state.vizData.formData,
+ metrics: state.refData.metrics,
+ columns: state.refData.columns,
+ selectedMetrics: state.settings.present.query.metrics,
+});
+
+const mapDispatchToProps = dispatch => ({
+ intervalCallback: (intervalStart, intervalEnd) =>
+ dispatch(changeInterval(intervalStart, intervalEnd)),
+ handleAddFilter: filter => dispatch(addFilter(filter)),
+ handleRemoveFilter: filter => dispatch(removeFilter(filter)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ChartContainer);
diff --git a/superset/assets/javascripts/swivel/components/Column.jsx b/superset/assets/javascripts/swivel/components/Column.jsx
new file mode 100644
index 000000000000..3d8711499da2
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/Column.jsx
@@ -0,0 +1,116 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Button, ButtonGroup, OverlayTrigger, Popover } from 'react-bootstrap';
+import { DragSource } from 'react-dnd';
+
+import ItemTypes from '../ItemTypes';
+import ColumnTypes from '../ColumnTypes';
+
+const style = {
+ cursor: 'move',
+ display: 'flex',
+ flexWrap: 'nowrap',
+ justifyContent: 'space-between',
+};
+
+const boxSource = {
+ beginDrag(props) {
+ return {
+ name: props.name,
+ id: props.id,
+ columnType: props.columnType,
+ groupable: props.groupable,
+ };
+ },
+};
+
+function collect(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging(),
+ };
+}
+
+const propTypes = {
+ connectDragSource: PropTypes.func.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ columnType: PropTypes.string.isRequired,
+ groupable: PropTypes.bool.isRequired,
+
+ handleAddFilter: PropTypes.func.isRequired,
+ handleAddSplit: PropTypes.func.isRequired,
+};
+
+class Column extends Component {
+ constructor(props) {
+ super(props);
+ this.popoverRight.bind(this);
+ }
+
+ popoverRight() {
+ const { handleAddFilter, handleAddSplit } = this.props;
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ render() {
+ const { name, isDragging, connectDragSource, columnType } = this.props;
+ const opacity = isDragging ? 0.4 : 1;
+ let icon = '';
+ if (columnType === ColumnTypes.TIMESTAMP) {
+ icon = 'fa-clock-o';
+ } else if (columnType === ColumnTypes.NUMERIC) {
+ icon = 'fa-hashtag';
+ } else if (columnType === ColumnTypes.STRING) {
+ icon = 'fa-language';
+ }
+
+ return connectDragSource(
+
,
+ );
+ }
+}
+Column.propTypes = propTypes;
+
+export default DragSource(ItemTypes.DIMENSION, boxSource, collect)(Column);
diff --git a/superset/assets/javascripts/swivel/components/ColumnContainer.jsx b/superset/assets/javascripts/swivel/components/ColumnContainer.jsx
new file mode 100644
index 000000000000..29450f6ce55d
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/ColumnContainer.jsx
@@ -0,0 +1,126 @@
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import React, { PureComponent } from 'react';
+import { Label, ListGroup, ListGroupItem, FormControl } from 'react-bootstrap';
+import Column from './Column';
+import ColumnTypes from '../ColumnTypes';
+import { addFilter, addSplit } from '../actions/querySettingsActions';
+
+const propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object),
+ columnSearchTrigger: PropTypes.bool,
+
+ handleAddFilter: PropTypes.func.isRequired,
+ handleAddSplit: PropTypes.func.isRequired,
+};
+
+class ColumnContainer extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ filter: '',
+ showFilter: false,
+ };
+ this.handleFilter = this.handleFilter.bind(this);
+ this.handleClear = this.handleClear.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.columnSearchTrigger !== this.props.columnSearchTrigger) {
+ this.setState({ showFilter: !this.state.showFilter });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if ((!prevState.showFilter && this.state.showFilter)
+ || (this.showFilter && prevState.filter !== this.state.filter)) {
+ this.filterRef.focus();
+ }
+ }
+
+ handleClear(e) {
+ if (e.keyCode === 27) {
+ this.setState({ filter: '' });
+ }
+ }
+
+ handleFilter(e) {
+ this.setState({ filter: e.target.value });
+ this.filterRef.focus();
+ }
+
+ render() {
+ const { columns, handleAddFilter, handleAddSplit } = this.props;
+ const { filter, showFilter } = this.state;
+ return (
+
+
+
+
+
+ {
+ showFilter &&
+ { this.filterRef = ref; }}
+ type="text"
+ value={filter}
+ placeholder="Search columns"
+ onChange={this.handleFilter}
+ onKeyDown={this.handleClear}
+ />
+
+ }
+ {columns.sort((a, b) => {
+ if (a.columnType === ColumnTypes.TIMESTAMP &&
+ b.columnType !== ColumnTypes.TIMESTAMP) {
+ return -1;
+ } else if (a.columnType !== ColumnTypes.TIMESTAMP &&
+ b.columnType === ColumnTypes.TIMESTAMP) {
+ return 1;
+ }
+ return a.name.localeCompare(b.name);
+ })
+ .filter(x => !filter ||
+ x.name.toLowerCase().includes(filter.toLowerCase()))
+ .map(({ name, id, columnType, groupable }, index) =>
+ (
+
+ ),
+ )}
+
+
+ );
+ }
+}
+
+ColumnContainer.propTypes = propTypes;
+const mapDispatchToProps = dispatch => ({
+ handleAddFilter: (filter) => {
+ dispatch(addFilter(filter));
+ },
+ handleAddSplit: (split) => {
+ dispatch(addSplit(split));
+ },
+});
+
+const mapStateToProps = state => ({
+ columns: state.refData.columns,
+ columnSearchTrigger: state.keyBindings.columnSearchTrigger,
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnContainer);
diff --git a/superset/assets/javascripts/swivel/components/ColumnDropTarget.jsx b/superset/assets/javascripts/swivel/components/ColumnDropTarget.jsx
new file mode 100644
index 000000000000..c5120d33b678
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/ColumnDropTarget.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DropTarget } from 'react-dnd';
+
+import ItemTypes from '../ItemTypes';
+
+const splitTarget = {
+ drop(props, monitor) {
+ props.onDrop(monitor.getItem());
+ },
+};
+
+function collect(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver(),
+ canDrop: monitor.canDrop(),
+ };
+}
+
+const propTypes = {
+ connectDropTarget: PropTypes.func.isRequired,
+ isOver: PropTypes.bool.isRequired,
+ canDrop: PropTypes.bool.isRequired,
+ accepts: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onDrop: PropTypes.func,
+
+ name: PropTypes.string.isRequired,
+};
+
+function ColumnDropTarget(props) {
+ const { name, connectDropTarget, children } = props;
+ return connectDropTarget(
+
+
+
{name}
+
+ {children}
+
+
+
,
+ );
+}
+
+ColumnDropTarget.propTypes = propTypes;
+export default DropTarget(ItemTypes.DIMENSION, splitTarget, collect)(ColumnDropTarget);
diff --git a/superset/assets/javascripts/swivel/components/Container.jsx b/superset/assets/javascripts/swivel/components/Container.jsx
new file mode 100644
index 000000000000..ba1462754689
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/Container.jsx
@@ -0,0 +1,52 @@
+/* eslint camelcase: 0 */
+import React from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+
+import ChartContainer from './ChartContainer';
+import MetricContainer from './MetricContainer';
+import ColumnContainer from './ColumnContainer';
+import RunToolbar from './RunToolbar';
+import SettingsPanel from './SettingsPanel';
+
+import QuerySettingsListener from '../listeners/QuerySettingsListener';
+import VizSettingsListener from '../listeners/VizSettingsListener';
+
+import DatasourceSelect from '../containers/DatasourceSelect';
+import FilterContainer from '../containers/FilterContainer';
+import SplitContainer from '../containers/SplitContainer';
+import VizTypeSelect from '../containers/VizTypeSelect';
+
+
+const style = {
+ backgroundColor: 'white',
+};
+
+function Container() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default DragDropContext(HTML5Backend)(Container);
diff --git a/superset/assets/javascripts/swivel/components/DateFilter.jsx b/superset/assets/javascripts/swivel/components/DateFilter.jsx
new file mode 100644
index 000000000000..9491f007734b
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/DateFilter.jsx
@@ -0,0 +1,196 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Button, FormControl, InputGroup, Glyphicon,
+} from 'react-bootstrap';
+import Select from 'react-select';
+import Datetime from 'react-datetime';
+import 'react-datetime/css/react-datetime.css';
+import moment from 'moment';
+
+import PopoverSection from '../../components/PopoverSection';
+
+const RELATIVE_TIME_OPTIONS = ['ago', 'from now'];
+const TIME_GRAIN_OPTIONS = ['seconds', 'minutes', 'days', 'weeks', 'months', 'years'];
+
+const propTypes = {
+ onChange: PropTypes.func,
+ handleSubmit: PropTypes.func,
+
+ value: PropTypes.string,
+ clearButton: PropTypes.bool,
+ nowButton: PropTypes.bool,
+};
+
+const defaultProps = {
+ onChange: () => {},
+ handleSubmit: () => {},
+ value: '',
+ clearButton: false,
+ nowButton: false,
+};
+
+export default class DateFilter extends React.Component {
+ constructor(props) {
+ super(props);
+ const value = props.value || '';
+ this.state = {
+ num: '7',
+ grain: 'days',
+ rel: 'ago',
+ dttm: '',
+ type: 'free',
+ free: '',
+ };
+ const words = value.split(' ');
+ if (words.length >= 3 && RELATIVE_TIME_OPTIONS.indexOf(words[2]) >= 0) {
+ this.state.num = words[0];
+ this.state.grain = words[1];
+ this.state.rel = words[2];
+ this.state.type = 'rel';
+ } else if (moment(value).isValid()) {
+ this.state.dttm = value;
+ this.state.type = 'fix';
+ } else {
+ this.state.free = value;
+ this.state.type = 'free';
+ }
+ }
+
+ onControlChange(target, opt) {
+ this.setState({ [target]: opt.value }, this.onChange);
+ }
+ onNumberChange(event) {
+ this.setState({ num: event.target.value }, this.onChange);
+ }
+ onChange() {
+ let val;
+ if (this.state.type === 'rel') {
+ val = `${this.state.num} ${this.state.grain} ${this.state.rel}`;
+ } else if (this.state.type === 'fix') {
+ val = this.state.dttm;
+ } else if (this.state.type === 'free') {
+ val = this.state.free;
+ }
+ this.props.onChange(val);
+ }
+ onFreeChange(event) {
+ this.setState({ free: event.target.value }, this.onChange);
+ }
+ setType(type) {
+ this.setState({ type }, this.onChange);
+ }
+ setValue(val) {
+ this.setState({ type: 'free', free: val }, this.onChange);
+ }
+ setDatetime(dttm) {
+ this.setState({ dttm: dttm.format().substring(0, 19) }, this.onChange);
+ }
+
+ render() {
+ const { clearButton, nowButton, handleSubmit } = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ nowButton &&
+
+ }
+ {
+ clearButton &&
+
+ }
+
+
+
+ );
+ }
+}
+
+DateFilter.propTypes = propTypes;
+DateFilter.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/swivel/components/FilterTile.jsx b/superset/assets/javascripts/swivel/components/FilterTile.jsx
new file mode 100644
index 000000000000..39392ddb3253
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/FilterTile.jsx
@@ -0,0 +1,312 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { Button, Overlay,
+ Popover, Badge, Label } from 'react-bootstrap';
+
+import ColumnTypes from '../ColumnTypes';
+import DateFilter from './DateFilter';
+import RangeFilter from './RangeFilter';
+import ValueFilter from './ValueFilter';
+
+
+const propTypes = {
+ name: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ columnType: PropTypes.string.isRequired,
+ groupable: PropTypes.bool.isRequired,
+ filter: PropTypes.arrayOf(PropTypes.string),
+ intervalStart: PropTypes.string,
+ intervalEnd: PropTypes.string,
+ invert: PropTypes.bool,
+ like: PropTypes.bool,
+
+
+ leftOpen: PropTypes.bool,
+ rightOpen: PropTypes.bool,
+
+ remove: PropTypes.func,
+ configure: PropTypes.func,
+ datasource: PropTypes.string,
+};
+
+// TODO Refactor this and move more of the logic into the individual filters.
+// SplitTile should be able to reuse some of the code
+export default class FilterTile extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = this.nextState(props);
+ this.handleUpdates = this.handleUpdates.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+ this.handleHide = this.handleHide.bind(this);
+ this.isSet = this.isSet.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const nextState = this.nextState(nextProps);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ /** This handles default state values as well as handling state changes
+ * when undo/redo actions are performed
+ * @param props
+ * @returns the local component state that should follow
+ */
+ nextState(props) {
+ if (!this.state) {
+ return {
+ groupable: props.groupable && props.columnType !== ColumnTypes.TIMESTAMP,
+ showOverlay: !this.isSet(props),
+ filter: props.filter,
+ intervalStart: props.intervalStart,
+ intervalEnd: props.intervalEnd,
+ invert: props.invert,
+ like: props.like,
+ leftOpen: props.leftOpen,
+ rightOpen: props.rightOpen,
+ };
+ }
+ if (!this.state.showOverlay && !this.isSet(this.state)) {
+ props.remove({ id: this.props.id });
+ } else if (!this.state.groupable) {
+ if (this.state.intervalStart !== props.intervalStart ||
+ this.state.intervalEnd !== props.intervalEnd) {
+ return {
+ showOverlay: false,
+ intervalStart: props.intervalStart,
+ intervalEnd: props.intervalEnd,
+ filter: [],
+ invert: props.invert,
+ like: props.like,
+ leftOpen: props.leftOpen,
+ rightOpen: props.rightOpen,
+ };
+ }
+ } else if (JSON.stringify(this.state.filter) !==
+ JSON.stringify(props.filter)) {
+ return {
+ showOverlay: false,
+ intervalStart: null,
+ intervalEnd: null,
+ filter: props.filter,
+ invert: props.invert,
+ like: props.like,
+ leftOpen: props.leftOpen,
+ rightOpen: props.rightOpen,
+ };
+ }
+ return null;
+ }
+
+ // This gets called when the Overlay is closed and changes are not OKed
+ handleHide() {
+ if (!this.isSet(this.props)) {
+ this.props.remove({ id: this.props.id });
+ } else {
+ this.setState({
+ intervalStart: this.props.intervalStart,
+ intervalEnd: this.props.intervalEnd,
+ filter: this.props.filter,
+ invert: this.props.invert,
+ like: this.like,
+ leftOpen: this.props.leftOpen,
+ rightOpen: this.props.rightOpen,
+ showOverlay: false,
+ });
+ }
+ }
+
+ // Save changes to the filter
+ handleSubmit(target) {
+ if (!target || target.charCode === 13 || target.keyCode === 13) {
+ const { filter, intervalStart, intervalEnd, invert, like,
+ leftOpen, rightOpen, groupable } = this.state;
+ this.setState({ showOverlay: false });
+ if (!this.isSet(this.state)) {
+ this.props.remove({ id: this.props.id });
+ } else {
+ const { id, name, columnType } = this.props;
+ if (!groupable) {
+ this.props.configure({
+ id,
+ name,
+ columnType,
+ groupable,
+ filter: [],
+ intervalStart,
+ intervalEnd,
+ invert,
+ like: false,
+ leftOpen,
+ rightOpen,
+ });
+ } else {
+ this.props.configure({
+ id,
+ name,
+ columnType,
+ groupable,
+ filter,
+ like: filter.length === 1 ? like : false,
+ intervalStart: null,
+ intervalEnd: null,
+ invert,
+ leftOpen,
+ rightOpen,
+ });
+ }
+ }
+ }
+ }
+
+ // Checks if the filter is configured
+ isSet({ filter, intervalStart, intervalEnd }) {
+ if (this.props.columnType === ColumnTypes.TIMESTAMP) {
+ return (intervalStart && intervalStart.length);
+ }
+ return (filter.length ||
+ (intervalEnd && intervalEnd.length) ||
+ (intervalStart && intervalStart.length));
+ }
+
+ handleUpdates(e, callback) {
+ return this.setState(e, callback);
+ }
+
+ handleClick() {
+ this.setState({ showOverlay: true });
+ }
+
+ // This will render the settings.
+ renderControl() {
+ if (this.props.columnType === ColumnTypes.TIMESTAMP) {
+ return (
+
+
+
+ this.setState({ intervalStart: v })}
+ handleSubmit={this.handleSubmit}
+ />
+
+
+
+ this.setState({ intervalEnd: v })}
+ handleSubmit={this.handleSubmit}
+ />
+
+
+ );
+ } else if (!this.state.groupable) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ render() {
+ const { showOverlay, intervalStart, intervalEnd, filter, groupable } = this.state;
+ const { name, remove, id } = this.props;
+
+ let badgeName = '';
+ if (!groupable) {
+ badgeName = `${intervalStart || '-∞'} - ${intervalEnd || '∞'}`;
+ } else if (filter.length) {
+ if (filter.length === 1) {
+ badgeName = filter[0];
+ } else {
+ badgeName = filter.length;
+ }
+ }
+
+ return (
+
+
+
+
+ {this.renderControl()}
+
+
+
+
+
+
+
{name}
+
{badgeName}
+
+
+ );
+ }
+}
+
+FilterTile.defaultProps = {
+ filter: [],
+ intervalStart: '',
+ intervalEnd: '',
+ invert: false,
+ like: false,
+ leftOpen: false,
+ rightOpen: false,
+};
+
+FilterTile.propTypes = propTypes;
diff --git a/superset/assets/javascripts/swivel/components/LabeledSelect.jsx b/superset/assets/javascripts/swivel/components/LabeledSelect.jsx
new file mode 100644
index 000000000000..0a272e955112
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/LabeledSelect.jsx
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { Label } from 'react-bootstrap';
+import Select from 'react-select';
+
+
+const style = {
+ marginBottom: '1.5rem',
+};
+
+const propTypes = {
+ title: PropTypes.string,
+ options: PropTypes.arrayOf(PropTypes.object).isRequired,
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onInputKeyDown: PropTypes.func.isRequired,
+};
+
+export default class LabeledSelect extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { value: props.value };
+ this.onChange = this.onChange.bind(this);
+ }
+
+ // This detects if the local state will get changed from an outside event
+ // which allows making async calls on onChange while keeping
+ // the history intact.
+ componentWillUpdate(nextProps, nextState) {
+ if (nextProps.value !== this.props.value) {
+ const { title, value } = nextProps;
+ if (value !== nextState.value) {
+ this.onChange({ title, value });
+ }
+ }
+ }
+
+ onChange(ds) {
+ this.setState({ value: ds.value });
+ this.props.onChange(ds);
+ }
+
+ render() {
+ const { title, options, value, onInputKeyDown } = this.props;
+ return (
+
+
+
+
+ );
+ }
+}
+
+LabeledSelect.defaultProps = {
+ onChange: () => {},
+ onInputKeyDown: () => {},
+};
+
+LabeledSelect.propTypes = propTypes;
diff --git a/superset/assets/javascripts/swivel/components/MetricContainer.jsx b/superset/assets/javascripts/swivel/components/MetricContainer.jsx
new file mode 100644
index 000000000000..ea8ad9517fbd
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/MetricContainer.jsx
@@ -0,0 +1,141 @@
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { Label, ListGroup, ListGroupItem, FormControl } from 'react-bootstrap';
+import { toggleMetric } from '../actions/querySettingsActions';
+import { toggleSeparateCharts } from '../actions/vizSettingsActions';
+
+const propTypes = {
+ metrics: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selected: PropTypes.object.isRequired,
+ separateCharts: PropTypes.bool,
+ handleChange: PropTypes.func.isRequired,
+ handleSeparateCharts: PropTypes.func,
+ splittable: PropTypes.bool,
+ metricSearchTrigger: PropTypes.bool,
+};
+
+class MetricContainer extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ filter: '',
+ showFilter: false,
+ };
+ this.handleFilter = this.handleFilter.bind(this);
+ this.handleClear = this.handleClear.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const { metrics, separateCharts, handleSeparateCharts, selected } = nextProps;
+ if (separateCharts && metrics.filter(x => !!selected[x.id]).length < 2) {
+ handleSeparateCharts();
+ }
+ if (nextProps.metricSearchTrigger !== this.props.metricSearchTrigger) {
+ this.setState({ showFilter: !this.state.showFilter });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if ((!prevState.showFilter && this.state.showFilter)
+ || (this.showFilter && prevState.filter !== this.state.filter)) {
+ this.filterRef.focus();
+ }
+ }
+
+ handleFilter(e) {
+ this.setState({ filter: e.target.value });
+ this.filterRef.focus();
+ }
+
+ handleClear(e) {
+ if (e.keyCode === 27) {
+ this.setState({ filter: '' });
+ }
+ }
+
+ render() {
+ const { metrics, handleChange, separateCharts,
+ handleSeparateCharts, selected, splittable } = this.props;
+ const { filter, showFilter } = this.state;
+ return (
+
+
+
+
+
+ {
+ showFilter &&
+ { this.filterRef = ref; }}
+ type="text"
+ value={filter}
+ placeholder="Search metrics"
+ onChange={this.handleFilter}
+ onKeyDown={this.handleClear}
+ />
+
+ }
+ { metrics.filter(x => selected[x.id] ||
+ !filter ||
+ x.name.toLowerCase().includes(filter.toLowerCase()))
+ .sort((a, b) => {
+ if (a && b) {
+ if (!!selected[a.id] === !!selected[b.id]) {
+ return a.name.localeCompare(b.name);
+ }
+ if (selected[a.id]) {
+ return -1;
+ }
+ return 1;
+ }
+ return 0;
+ })
+ .map((m, index) => (
+ handleChange({ id: m.id })}
+ >
+ {m.name}
+
+ ))}
+
+
+ );
+ }
+}
+
+MetricContainer.propTypes = propTypes;
+const mapStateToProps = state => ({
+ metrics: state.refData.metrics,
+ selected: state.settings.present.query.metrics,
+ separateCharts: state.settings.present.viz.separateCharts,
+ splittable: state.settings.present.query.vizType !== 'table',
+ metricSearchTrigger: state.keyBindings.metricSearchTrigger,
+});
+
+const mapDispatchToProps = dispatch => ({
+ handleChange: (metric) => { dispatch(toggleMetric(metric)); },
+ handleSeparateCharts: () => { dispatch(toggleSeparateCharts()); },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(MetricContainer);
diff --git a/superset/assets/javascripts/swivel/components/RangeFilter.jsx b/superset/assets/javascripts/swivel/components/RangeFilter.jsx
new file mode 100644
index 000000000000..42d3a9f91c6c
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/RangeFilter.jsx
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { Checkbox, FormControl, Label } from 'react-bootstrap';
+
+
+const propTypes = {
+ intervalStart: PropTypes.any,
+ intervalEnd: PropTypes.any,
+ leftOpen: PropTypes.bool,
+ rightOpen: PropTypes.bool,
+ onChange: PropTypes.func,
+ onSubmit: PropTypes.func,
+};
+
+class RangeFilter extends PureComponent {
+
+ formFormatter(e) {
+ return { [e.target.name]: e.target.value };
+ }
+
+ render() {
+ const { intervalStart, intervalEnd,
+ leftOpen, rightOpen, onChange, onSubmit } = this.props;
+ return (
+
+
+
onChange(this.formFormatter(e))}
+ onKeyPress={onSubmit}
+ bsSize="small"
+ />
+
+ onChange(this.formFormatter(e))}
+ onKeyPress={onSubmit}
+ bsSize="small"
+ />
+
+ onChange({ leftOpen: !leftOpen })}
+ >Left Open
+ onChange({ rightOpen: !rightOpen })}
+ >Right Open
+
+
+ );
+ }
+}
+
+RangeFilter.propTypes = propTypes;
+export default RangeFilter;
diff --git a/superset/assets/javascripts/swivel/components/RunToolbar.jsx b/superset/assets/javascripts/swivel/components/RunToolbar.jsx
new file mode 100644
index 000000000000..5e020d54ba50
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/RunToolbar.jsx
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { DropdownButton, ButtonGroup, Button,
+ SplitButton, MenuItem, OverlayTrigger, Popover, Well } from 'react-bootstrap';
+import { abort, setRun, setAutoRun, reset } from '../actions/globalActions';
+import { getExploreUrl, getSwivelUrl } from '../../explore/exploreUtils';
+import { getSessions, deleteSessions } from '../SessionManager';
+
+const propTypes = {
+ run: PropTypes.bool,
+ autoRun: PropTypes.bool,
+ isRunning: PropTypes.bool,
+ formData: PropTypes.object,
+ abortQuery: PropTypes.func,
+ runQuery: PropTypes.func,
+ setMode: PropTypes.func,
+ resetSwivel: PropTypes.func,
+ outdated: PropTypes.bool,
+ error: PropTypes.string,
+};
+
+class RunToolbar extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { sessions: [] };
+ }
+
+ popoverUrl(formData) {
+ const url = `${window.location.origin}${getSwivelUrl(formData, true)}`;
+ return (
+
+ {url}
+
+ );
+ }
+
+ runButtonStyle(autoRun, isRunning) {
+ if (isRunning) {
+ return 'warning';
+ }
+ return (autoRun ? 'success' : 'default');
+ }
+
+ renderSpinner() {
+ return (
+
+ Stop
+
+ );
+ }
+
+ render() {
+ const { autoRun, abortQuery, runQuery, setMode, isRunning,
+ resetSwivel, formData, error, outdated } = this.props;
+ const disabled = !formData || outdated || !!error;
+ let title = autoRun ? 'Auto Run' : 'Run';
+ title = isRunning ? this.renderSpinner() : title;
+ return (
+
+ );
+ }
+}
+
+RunToolbar.propTypes = propTypes;
+
+const mapStateToProps = state => ({
+ run: state.controls.run,
+ autoRun: state.controls.autoRun,
+ isRunning: state.controls.isRunning,
+ error: state.controls.error,
+ formData: state.vizData.formData,
+ outdated: state.vizData.outdated,
+});
+
+const mapDispatchToProps = dispatch => ({
+ runQuery: () => {
+ dispatch(setRun(true));
+ },
+ abortQuery: () => {
+ dispatch(abort());
+ },
+ setMode: (autoRun) => {
+ dispatch(setAutoRun(autoRun));
+ },
+ resetSwivel: (clearHistory) => {
+ dispatch(reset(clearHistory));
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(RunToolbar);
+
diff --git a/superset/assets/javascripts/swivel/components/SettingsPanel.jsx b/superset/assets/javascripts/swivel/components/SettingsPanel.jsx
new file mode 100644
index 000000000000..dc114a46d8a6
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/SettingsPanel.jsx
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { Panel, Checkbox } from 'react-bootstrap';
+import { toggleShowLegend } from '../actions/vizSettingsActions';
+
+const propTypes = {
+ showLegend: PropTypes.bool,
+ toggleLegend: PropTypes.func,
+};
+
+class SettingsPanel extends PureComponent {
+ render() {
+ const { showLegend, toggleLegend } = this.props;
+ return (
+
+ Show Legend
+
+ );
+ }
+}
+
+SettingsPanel.propTypes = propTypes;
+
+const mapStateToProps = state => ({
+ showLegend: state.settings.present.viz.showLegend,
+});
+
+const mapDispatchToProps = dispatch => ({
+ toggleLegend: () => dispatch(toggleShowLegend()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SettingsPanel);
diff --git a/superset/assets/javascripts/swivel/components/SplitTile.jsx b/superset/assets/javascripts/swivel/components/SplitTile.jsx
new file mode 100644
index 000000000000..44bf7ce374c2
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/SplitTile.jsx
@@ -0,0 +1,239 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Select from 'react-select';
+
+import { FormControl, Button, Overlay, Popover, Badge, Label } from 'react-bootstrap';
+import ColumnTypes from '../ColumnTypes';
+import LabeledSelect from './LabeledSelect';
+
+
+const propTypes = {
+ name: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ columnType: PropTypes.string.isRequired,
+ granularity: PropTypes.string,
+ justAdded: PropTypes.bool,
+
+ timeGrains: PropTypes.arrayOf(PropTypes.string),
+ metrics: PropTypes.arrayOf(PropTypes.object),
+ selectedMetrics: PropTypes.object,
+ remove: PropTypes.func,
+ configure: PropTypes.func,
+
+ limit: PropTypes.number,
+ orderBy: PropTypes.string,
+ orderDesc: PropTypes.bool,
+};
+
+const DEFAULT_LIMIT = 5;
+
+// TODO Refactor this and pull out the components for configuring splits
+// FilterTile should be able to reuse some of the code
+export default class SplitTile extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = this.nextState(props);
+ this.handleUpdates = this.handleUpdates.bind(this);
+ this.handleGranularity = this.handleGranularity.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+ this.handleHide = this.handleHide.bind(this);
+ }
+
+ componentWillMount() {
+ this.configure(this.props, this.state);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const nextState = this.nextState(nextProps);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ configure(props, state) {
+ const { id } = props;
+ const { limit, granularity, orderBy, orderDesc } = state;
+ this.props.configure({
+ id, limit, granularity, orderBy, orderDesc,
+ });
+ }
+
+ nextState(props) {
+ if (props.columnType === ColumnTypes.TIMESTAMP) {
+ if (!this.state ||
+ this.state.granularity !== props.granularity) {
+ return {
+ showOverlay: false,
+ granularity: props.granularity || props.timeGrains[props.timeGrains.length - 1],
+ limit: null,
+ orderBy: null,
+ orderDesc: null,
+ };
+ }
+ } else if (!this.state ||
+ this.state.limit !== props.limit ||
+ this.state.orderBy !== props.orderBy ||
+ this.state.orderDesc !== props.orderDesc
+ ) {
+ const firstSelected = () => props.metrics.find(x => props.selectedMetrics[x.id]) || {};
+ return {
+ showOverlay: false,
+ granularity: null,
+ limit: props.limit || DEFAULT_LIMIT,
+ orderBy: props.orderBy || firstSelected().id,
+ orderDesc: props.orderDesc,
+ };
+ }
+ return null;
+ }
+
+ handleSubmit(target) {
+ if (!target || target.charCode === 13 || target.keyCode === 13) {
+ this.configure(this.props, this.state);
+ this.setState({ showOverlay: false });
+ }
+ }
+
+ handleUpdates(e) {
+ this.setState({ [e.target.name]: e.target.value });
+ }
+
+ handleGranularity(e) {
+ this.setState({ granularity: e.value });
+ }
+
+ handleClick() {
+ this.setState({ showOverlay: true });
+ }
+
+ handleHide() {
+ this.setState({
+ granularity: this.props.granularity,
+ limit: this.props.limit,
+ orderBy: this.props.orderBy,
+ orderDesc: this.props.orderDesc,
+ showOverlay: false,
+ });
+ }
+
+ renderControl() {
+ const { columnType, timeGrains, metrics, selectedMetrics } = this.props;
+ if (columnType === ColumnTypes.TIMESTAMP) {
+ const grains = timeGrains.map(x => ({ value: x, label: x }));
+ return (
+
+
+
+ );
+ }
+ const ms = metrics.filter(m => selectedMetrics[m.id])
+ .map(m => ({ value: m.id, label: m.name }));
+ return (
+
+
+
+
+
+ {
+ this.setState({
+ limit: e.target.value });
+ }}
+ onKeyPress={this.handleSubmit}
+ bsSize="small"
+ />
+
+
+ );
+ }
+
+ render() {
+ const { showOverlay, granularity } = this.state;
+ const { name, remove, id, columnType } = this.props;
+ let badgeName = '';
+ if (columnType === ColumnTypes.TIMESTAMP) {
+ badgeName = granularity;
+ }
+ return (
+
+
+
+ {this.renderControl()}
+
+
+
+
+
+
{name}
+
{badgeName}
+
+
+ );
+ }
+}
+
+SplitTile.propTypes = propTypes;
diff --git a/superset/assets/javascripts/swivel/components/ValueFilter.jsx b/superset/assets/javascripts/swivel/components/ValueFilter.jsx
new file mode 100644
index 000000000000..b0003ee574f6
--- /dev/null
+++ b/superset/assets/javascripts/swivel/components/ValueFilter.jsx
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
+import { Checkbox, Label } from 'react-bootstrap';
+import { AsyncCreatable } from 'react-select';
+
+const PAGE_SIZE = 1000; // Max num records to be fetched per request
+const DEBOUNCE_MS = 400; // Time to wait for more input between key strokes
+const FILTER_REQUEST_TIMEOUT = 30000; // 30 seconds
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ datasource: PropTypes.string,
+ filter: PropTypes.array,
+ invert: PropTypes.bool,
+ like: PropTypes.bool,
+ onChange: PropTypes.func,
+};
+
+/**
+ * The ValueFilter loads options for auto complete incrementally and asynchronously
+ * while typing. It is debounced to prevent making new request for each key stroke,
+ * thus it will wait for DEBOUNCE_MS before making a request.
+ * Requests will be canceled if the result is not needed anymore.
+ * It also supports pipe delimited pasting of multiple values.
+ * */
+class ValueFilter extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.loadOptions = this.loadOptions.bind(this);
+ this.handlePaste = this.handlePaste.bind(this);
+ this.state = {
+ // These caches are used for the auto complete filter
+ cache: {},
+ completeCache: {},
+ currentSearchString: '',
+ optionsDebounce: {},
+ optionsRequest: {},
+ };
+ }
+
+ componentWillUnmount() {
+ // Cancel requests that are still running
+ if (this.state.optionsRequest.abort) {
+ this.state.optionsRequest.abort();
+ }
+ }
+
+ // This will handle pasting pipe delimited data into the filter select
+ handlePaste(e) {
+ e.preventDefault();
+ const pastedValues = e.clipboardData.getData('Text').split('|');
+ this.props.onChange({ filter: [...this.props.filter, ...pastedValues] });
+ }
+
+ handleFilter(e) {
+ return { filter: e.map(f => f.value) };
+ }
+
+ loadOptions(searchString) {
+ // Debounce request
+ if (searchString && this.state.currentSearchString !== searchString) {
+ return {};
+ }
+ // Cancel requests that are still running
+ if (this.state.optionsRequest.abort) {
+ this.state.optionsRequest.abort();
+ }
+
+ const { datasource, id } = this.props;
+ const completeCache = this.state.completeCache;
+
+ const cacheKeyBase = `${datasource}@${id}@`;
+ const [dsId, dsType] = datasource.split('__');
+ const url = `/superset/filter/${dsType}/${dsId}/${id}/${PAGE_SIZE}/${searchString}`;
+ for (let i = 0; i <= searchString.length; i++) {
+ const options = completeCache[`${cacheKeyBase}${searchString.substring(0, i)}`];
+ if (options) {
+ return Promise.resolve({ options });
+ }
+ }
+ const queryRequest = $.ajax({
+ url,
+ dataType: 'json',
+ timeout: FILTER_REQUEST_TIMEOUT,
+ });
+ this.setState({ optionsRequest: queryRequest });
+ return queryRequest.then((json) => {
+ const options = json.map(x => ({ value: x, label: x }));
+ if (json.length < PAGE_SIZE) {
+ this.setState({
+ completeCache: Object.assign(completeCache, {
+ [`${cacheKeyBase}${searchString}`]: options,
+ }),
+ });
+ } else {
+ this.setState({
+ cache: Object.assign(this.state.cache, {
+ [`${cacheKeyBase}${searchString}`]: options,
+ }),
+ });
+ }
+ return {
+ options,
+ complete: json.length < PAGE_SIZE,
+ };
+ });
+ }
+
+ render() {
+ const { filter, invert, like, onChange } = this.props;
+ return (
+
+
+
({ label: f, value: f }))}
+ onChange={e => onChange(this.handleFilter(e))}
+ loadOptions={
+ str => (new Promise(resolve => this.setState(
+ { currentSearchString: str }, resolve)))
+ .then(() => new Promise(resolve => setTimeout(resolve, DEBOUNCE_MS)))
+ .then(() => this.loadOptions(str))
+ }
+ promptTextCreator={s => s}
+ />
+ onChange({ invert: !invert })}
+ >Invert
+ {
+ filter.length <= 1 &&
+ onChange({ like: !like })}
+ >Like/Regex
+ }
+
+
+ );
+ }
+}
+
+ValueFilter.propTypes = propTypes;
+export default ValueFilter;
diff --git a/superset/assets/javascripts/swivel/constants.js b/superset/assets/javascripts/swivel/constants.js
new file mode 100644
index 000000000000..804ce571a0a2
--- /dev/null
+++ b/superset/assets/javascripts/swivel/constants.js
@@ -0,0 +1,3 @@
+export const MAX_NUM_SESSIONS = 10;
+export const LOCAL_STORAGE_SESSIONS_KEY = 'swivel';
+export const LOCAL_STORAGE_KEY_PREFIX = 'swivel_session_';
diff --git a/superset/assets/javascripts/swivel/containers/DatasourceSelect.js b/superset/assets/javascripts/swivel/containers/DatasourceSelect.js
new file mode 100644
index 000000000000..fb18582c2987
--- /dev/null
+++ b/superset/assets/javascripts/swivel/containers/DatasourceSelect.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { setDatasource } from '../actions/querySettingsActions';
+import LabeledSelect from '../components/LabeledSelect';
+
+const mapStateToProps = state => ({
+ title: 'Datasource',
+ options: state.refData.datasources.map(({ name, uid }) => ({ value: uid, label: name })),
+ value: state.settings.present.query.datasource,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onChange: (datasource) => {
+ dispatch(setDatasource(datasource.value));
+ },
+});
+
+const DatasourceSelect = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(LabeledSelect);
+
+export default DatasourceSelect;
diff --git a/superset/assets/javascripts/swivel/containers/FilterContainer.jsx b/superset/assets/javascripts/swivel/containers/FilterContainer.jsx
new file mode 100644
index 000000000000..c6058184f78c
--- /dev/null
+++ b/superset/assets/javascripts/swivel/containers/FilterContainer.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { addFilter, configureFilter, removeFilter } from '../actions/querySettingsActions';
+import ColumnDropTarget from '../components/ColumnDropTarget';
+import FilterTile from '../components/FilterTile';
+import ContainerTypes from '../ContainerTypes';
+import ItemTypes from '../ItemTypes';
+
+
+const mapTileDispatchToProps = dispatch => ({
+ configure: (filter) => {
+ dispatch(configureFilter(filter));
+ },
+ remove: (filter) => {
+ dispatch(removeFilter(filter));
+ },
+});
+
+const Tile = connect((state, ownProps) => ownProps, mapTileDispatchToProps)(FilterTile);
+
+function children(state) {
+ return state.settings.present.query.filters.map((tile, i) => (
+
+ ));
+}
+
+const mapStateToProps = state => ({
+ name: 'Filters',
+ datasource: state.settings.present.query.datasource,
+ type: ContainerTypes.FILTER,
+ children: children(state),
+ accepts: [ItemTypes.DIMENSION],
+});
+
+const mapDispatchToProps = dispatch => ({
+ onDrop: (filter) => {
+ dispatch(addFilter(filter));
+ },
+});
+
+const FilterContainer = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(ColumnDropTarget);
+
+export default FilterContainer;
diff --git a/superset/assets/javascripts/swivel/containers/SplitContainer.jsx b/superset/assets/javascripts/swivel/containers/SplitContainer.jsx
new file mode 100644
index 000000000000..72abd1f011d2
--- /dev/null
+++ b/superset/assets/javascripts/swivel/containers/SplitContainer.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { addSplit, configureSplit, removeSplit } from '../actions/querySettingsActions';
+import ColumnDropTarget from '../components/ColumnDropTarget';
+import SplitTile from '../components/SplitTile';
+import ItemTypes from '../ItemTypes';
+
+
+const mapTileDispatchToProps = dispatch => ({
+ configure: (split) => {
+ dispatch(configureSplit(split));
+ },
+ remove: (split) => {
+ dispatch(removeSplit(split));
+ },
+});
+
+const Tile = connect((state, ownProps) => ownProps, mapTileDispatchToProps)(SplitTile);
+
+function children(state) {
+ return state.settings.present.query.splits.map((tile, i) => (
+
+ ),
+ );
+}
+
+const mapStateToProps = state => ({
+ name: 'Group by',
+ accepts: [ItemTypes.DIMENSION],
+ children: children(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onDrop: (split) => {
+ dispatch(addSplit(split));
+ },
+});
+
+
+const SplitContainer = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(ColumnDropTarget);
+
+export default SplitContainer;
diff --git a/superset/assets/javascripts/swivel/containers/VizTypeSelect.js b/superset/assets/javascripts/swivel/containers/VizTypeSelect.js
new file mode 100644
index 000000000000..701a62f6fbfe
--- /dev/null
+++ b/superset/assets/javascripts/swivel/containers/VizTypeSelect.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { setVizType } from '../actions/querySettingsActions';
+import LabeledSelect from '../components/LabeledSelect';
+
+const mapStateToProps = state => ({
+ title: 'Visualisation Type',
+ options: state.refData.viz_types.map(({ name, id }) => ({ value: id, label: name })),
+ value: state.settings.present.query.vizType,
+});
+
+const mapDispatchToProps = dispatch => ({
+ onChange: (vizType) => {
+ dispatch(setVizType(vizType.value));
+ },
+});
+
+const VizTypeSelect = connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(LabeledSelect);
+
+export default VizTypeSelect;
diff --git a/superset/assets/javascripts/swivel/formDataUtils/convertToFormData.js b/superset/assets/javascripts/swivel/formDataUtils/convertToFormData.js
new file mode 100644
index 000000000000..a6081df5c6cb
--- /dev/null
+++ b/superset/assets/javascripts/swivel/formDataUtils/convertToFormData.js
@@ -0,0 +1,224 @@
+/* eslint camelcase: 0 */
+import ColumnTypes from '../ColumnTypes';
+import { VIZ_TYPES } from '../../../visualizations/main';
+
+export function isSupportedBySwivel(formData) {
+ if (formData) {
+ // not all query types are supported
+ if (!!formData.having || // no support for SQL `having`
+ !!formData.where || // no support for custom SQL `where` clause
+ // no support of Druid `having filters`
+ (Array.isArray(formData.having_filters) && !!formData.having_filters.length)) {
+ return false;
+ }
+ // Supported time series charts
+ if ((formData.viz_type === VIZ_TYPES.line ||
+ formData.viz_type === VIZ_TYPES.area ||
+ formData.viz_type === VIZ_TYPES.bar)
+ ) return true;
+ // Swivel only supports `grouped by` tables with metrics
+ if (formData.viz_type === VIZ_TYPES.table &&
+ Array.isArray(formData.metrics) &&
+ !!formData.metrics.length) {
+ return true;
+ }
+ }
+ return false;
+}
+
+
+export function convertVizSettingsToFormData(vizSettings) {
+ function convertPresentationToFormData() {
+ return {
+ show_legend: vizSettings.showLegend,
+ rich_tooltip: vizSettings.richTooltip,
+ separate_charts: vizSettings.separateCharts,
+
+ bottom_margin: 0,
+ color_scheme: 'bnbColors',
+ line_interpolation: 'linear',
+
+ x_axis_format: 'smart_date',
+ // x_axis_showminmax: true,
+
+ y_axis_format: '.3s',
+
+ // y_axis_showminmax: true,
+ y_log_scale: false,
+
+
+ rightAlignYAxis: true,
+
+ table_timestamp_format: 'smart_date',
+ // This makes tables interactive
+ table_filter: true,
+ // Mandatory for exploreview:
+ // LineChart:
+ y_axis_bounds: [null, null],
+ };
+ }
+ const formData = Object.assign({},
+ convertPresentationToFormData(),
+ );
+ return formData;
+}
+
+export function convertQuerySettingsToFormData(querySettings) {
+ let error;
+
+ const isSql = querySettings.datasource.endsWith('__table');
+
+ function convertMetricsToformData() {
+ const metricIds = Object.keys(querySettings.metrics);
+ if (!metricIds.length) {
+ error = 'Please select at least one metric.';
+ return {};
+ }
+ return { metrics: metricIds };
+ }
+
+ function convertGroupBysToformData() {
+ const splits = querySettings.splits;
+ const limit = querySettings.limit;
+ const orderBy = querySettings.orderBy;
+ const orderDesc = querySettings.orderDesc;
+
+ const timesplit = splits.find(x => x.columnType === ColumnTypes.TIMESTAMP);
+ let allSplits = splits;
+ let granularity = null;
+ if (timesplit) {
+ allSplits = splits.filter(x => x.id !== timesplit.id);
+ granularity = timesplit.granularity;
+ if (!granularity) {
+ error = 'Please set a granularity on your time `group by`.';
+ return {};
+ }
+ } else if (querySettings.vizType !== 'table') {
+ error = 'Please `group by` a time column.';
+ }
+
+ const groupby = allSplits.map(x => x.id);
+
+ let rowLimit = null;
+ if (!timesplit && limit) {
+ rowLimit = limit;
+ }
+
+ const o = {
+ include_time: !!granularity,
+ timeseries_limit_metric: orderBy,
+ order_desc: orderDesc,
+ groupby,
+ limit,
+ row_limit: rowLimit,
+ };
+
+ if (querySettings.datasource.endsWith('__druid')) {
+ o.granularity = granularity;
+ } else {
+ o.time_grain_sqla = granularity;
+ }
+
+ return o;
+ }
+
+ function convertFiltersToFormData() {
+ const filters = querySettings.filters;
+
+ const timefilter = filters.find(x => x.columnType === ColumnTypes.TIMESTAMP);
+ if (timefilter && timefilter.intervalStart) {
+ const since = timefilter.intervalStart;
+ const until = timefilter.intervalEnd;
+
+ const allFilters = filters.filter(x => x.id !== timefilter.id);
+ const groupableFilters = allFilters.filter(x =>
+ x.groupable);
+ const intervalFilters = allFilters.filter(x =>
+ !x.groupable);
+
+
+ let outFilters = groupableFilters.map((x) => {
+ let op;
+ let val = x.filter || [];
+ if (val.length > 1) {
+ if (x.invert) {
+ op = 'not in';
+ } else {
+ op = 'in';
+ }
+ } else if (val.length === 1) {
+ val = val[0];
+ if (x.columnType === ColumnTypes.STRING && !isSql) {
+ if (x.invert) {
+ op = '!=';
+ } else if (x.like) {
+ op = 'regex';
+ } else {
+ op = '==';
+ }
+ } else
+ if (x.columnType === ColumnTypes.STRING) {
+ if (x.like) {
+ if (x.invert) {
+ op = 'not like';
+ } else {
+ op = 'like';
+ }
+ } else if (x.invert) {
+ op = '!=';
+ } else {
+ op = '==';
+ }
+ } else if (x.invert) {
+ op = '!=';
+ } else {
+ op = '==';
+ }
+ }
+ return {
+ col: x.id,
+ op,
+ val,
+ };
+ });
+ outFilters = outFilters.concat(intervalFilters.filter(x => x.intervalStart).map(x => ({
+ col: x.id,
+ op: x.leftOpen ? '>' : '>=',
+ val: x.intervalStart,
+ })));
+ outFilters = outFilters.concat(intervalFilters.filter(x => x.intervalEnd).map(x => ({
+ col: x.id,
+ op: x.rightOpen ? '<' : '<=',
+ val: x.intervalEnd,
+ })));
+
+ const o = {
+ since,
+ until,
+ filters: outFilters,
+ };
+
+ if (querySettings.datasource.endsWith('__table')) {
+ o.granularity_sqla = timefilter.id;
+ }
+ return o;
+ }
+ error = 'Please add a `filter` on a time column.';
+ return {};
+ }
+
+ const formData = Object.assign({},
+ {
+ datasource: querySettings.datasource,
+ viz_type: querySettings.vizType,
+ },
+ convertMetricsToformData(),
+ convertFiltersToFormData(),
+ convertGroupBysToformData(),
+ );
+ if (error) {
+ formData.error = error;
+ }
+ return formData;
+}
+
diff --git a/superset/assets/javascripts/swivel/formDataUtils/importQuerySettings.js b/superset/assets/javascripts/swivel/formDataUtils/importQuerySettings.js
new file mode 100644
index 000000000000..dbe4d3b1a19f
--- /dev/null
+++ b/superset/assets/javascripts/swivel/formDataUtils/importQuerySettings.js
@@ -0,0 +1,196 @@
+/* eslint camelcase: 0 */
+import ColumnTypes from '../ColumnTypes';
+
+class Column {
+ constructor(...args) {
+ this.columnType = null;
+ this.id = '';
+ this.name = '';
+ Object.assign(this, ...args);
+ }
+}
+
+class IntervalFilter extends Column {
+ importFormData(term) {
+ const op = term.op;
+ if (!this.intervalStart) {
+ this.intervalStart = '';
+ this.leftOpen = false;
+ }
+ if (!this.intervalEnd) {
+ this.intervalEnd = '';
+ this.rightOpen = false;
+ }
+ if (op === '>') {
+ this.id = term.col;
+ this.intervalStart = term.val;
+ this.leftOpen = true;
+ } else if (op === '>=') {
+ this.id = term.col;
+ this.intervalStart = term.val;
+ this.leftOpen = false;
+ } else if (op === '<') {
+ this.id = term.col;
+ this.intervalEnd = term.val;
+ this.rightOpen = true;
+ } else if (op === '<=') {
+ this.id = term.col;
+ this.intervalEnd = term.val;
+ this.rightOpen = false;
+ } else {
+ this.error = `The filter operation "${op}" on column` +
+ `"${term.col}" is not supported.`;
+ }
+ return true;
+ }
+}
+
+const explicitFilterOps = [
+ '==', 'like', 'regex', '!=', 'not like', 'in', 'not in',
+];
+
+class ExplicitFilter extends Column {
+ importFormData(term) {
+ if (!Array.isArray(this.filter)) {
+ this.filter = [];
+ }
+ this.invert = false;
+ const op = term.op.toLowerCase();
+ if (op === '==') {
+ this.id = term.col;
+ this.filter.push(term.val);
+ this.invert = false;
+ this.like = false;
+ } else if (op === 'like' || op === 'regex') {
+ this.id = term.col;
+ this.filter.push(term.val);
+ this.invert = false;
+ this.like = true;
+ } else if (op === '!=') {
+ this.id = term.col;
+ this.filter.push(term.val);
+ this.invert = true;
+ this.like = false;
+ } else if (op === 'not like') {
+ this.id = term.col;
+ this.filter.push(term.val);
+ this.invert = true;
+ this.like = true;
+ } else if (op === 'in') {
+ this.filter.push(...term.val);
+ this.invert = false;
+ } else if (op === 'not in') {
+ this.filter.push(...term.val);
+ this.invert = true;
+ } else {
+ this.error = `This combination of filters on ${term.col}` +
+ 'is not supported.';
+ }
+ return true;
+ }
+}
+
+export function importFormData(querySettingsStore, formData, refData) {
+ const store = querySettingsStore;
+ const isSql = formData.datasource.endsWith('__table');
+
+ function convertVizFromFormData({ viz_type, datasource }) {
+ store.datasource = datasource;
+ store.vizType = viz_type;
+ }
+
+ function convertMetricsFromFormData(metrics) {
+ if (metrics) {
+ store.metrics = metrics.reduce((mets, m) => Object.assign(mets, { [m]: true }), {});
+ }
+ }
+
+ function convertTime({ since, until,
+ granularity,
+ granularity_sqla,
+ time_grain_sqla,
+ include_time,
+ viz_type }) {
+ let column;
+ if (isSql) {
+ column = refData.columns.find(m => m.columnType === ColumnTypes.TIMESTAMP &&
+ m.id === granularity_sqla);
+ }
+ if (!column) {
+ column = refData.columns.find(m => m.columnType === ColumnTypes.TIMESTAMP);
+ }
+ const t = new IntervalFilter(column);
+ t.intervalStart = since;
+ t.intervalEnd = until;
+ store.filters.push(t);
+ if (viz_type !== 'table' || include_time) {
+ store.splits.push({
+ ...column,
+ granularity: isSql ? time_grain_sqla : granularity,
+ });
+ }
+ }
+
+ function convertFiltersFromFormData(filters, columns) {
+ if (filters) {
+ const grouped = filters.reduce((group, f) => {
+ const g = group;
+ if (g[f.col]) {
+ g[f.col].push(f);
+ } else {
+ g[f.col] = [f];
+ }
+ return g;
+ }, {});
+ for (const id of Object.keys(grouped)) {
+ const column = columns[id];
+ const first = grouped[id][0].op.toLowerCase();
+ if (explicitFilterOps.find(x => x === first)) {
+ for (const term of grouped[id]) {
+ const filter = new ExplicitFilter(column);
+ filter.importFormData(term);
+ store.filters.push(filter);
+ }
+ } else {
+ if (grouped[id].length > 2) {
+ store.setError('Only one interval filter per ' +
+ `column supported; column id: ${first.col}`);
+ }
+ const filter = new IntervalFilter(column);
+ for (const term of grouped[id]) {
+ filter.importFormData(term);
+ }
+ store.filters.push(filter);
+ }
+ }
+ }
+ }
+
+ function convertGroupBysFromFormData({ timeseries_limit_metric,
+ order_desc,
+ groupby,
+ limit,
+ row_limit,
+ }, columns) {
+ if (limit === undefined || limit === null) {
+ store.limit = row_limit;
+ } else if (row_limit === undefined || row_limit === null) {
+ store.limit = limit;
+ } else {
+ store.limit = Math.min(limit, row_limit);
+ }
+ store.orderDesc = !!order_desc;
+ store.orderBy = timeseries_limit_metric;
+ if (groupby) {
+ groupby.forEach(g => store.splits.push(columns[g]));
+ }
+ }
+
+ const columns = refData.columns.reduce((cols, c) => Object.assign(cols, { [c.id]: c }), {});
+ convertVizFromFormData(formData);
+ convertTime(formData);
+ convertFiltersFromFormData(formData.filters, columns);
+ convertGroupBysFromFormData(formData, columns);
+ convertMetricsFromFormData(formData.metrics);
+ return store;
+}
diff --git a/superset/assets/javascripts/swivel/formDataUtils/importVizSettings.js b/superset/assets/javascripts/swivel/formDataUtils/importVizSettings.js
new file mode 100644
index 000000000000..bef384d30b07
--- /dev/null
+++ b/superset/assets/javascripts/swivel/formDataUtils/importVizSettings.js
@@ -0,0 +1,7 @@
+/* eslint camelcase: 0 */
+export function importFormData(vizSettingsStore, formData) {
+ const store = vizSettingsStore;
+ store.showLegend = formData.show_legend;
+ store.separateCharts = formData.separate_charts;
+ return store;
+}
diff --git a/superset/assets/javascripts/swivel/formDataUtils/sliceObject.js b/superset/assets/javascripts/swivel/formDataUtils/sliceObject.js
new file mode 100644
index 000000000000..6eb48ce5c366
--- /dev/null
+++ b/superset/assets/javascripts/swivel/formDataUtils/sliceObject.js
@@ -0,0 +1,52 @@
+/* eslint camelcase: 0 */
+import { d3format } from '../../modules/utils';
+import { getExploreUrl } from '../../explore/exploreUtils';
+
+export function getMockedSliceObject(
+ containerId, formData, metrics = [], numCharts = 1,
+ addFilter = () => {},
+ removeFilter = () => {},
+) {
+ const selector = `#${containerId}`;
+ const getHeight = () => Math.max(320, ($('#swivel-side-bar').height() -
+ $('#swivel-drop-targets').height()) / numCharts);
+ const getWidth = () => $(selector).width();
+ const id_map = metrics.reduce((lookup, m) => ({ ...lookup, [m.id]: m }), {});
+
+ return {
+ viewSqlQuery: '',
+ containerId: `${containerId}`,
+ datasource: { verbose_map: metrics.reduce((lookup, m) => ({ ...lookup, [m.id]: m.name }), {}) },
+ selector,
+ formData,
+ container: {
+ html: (data) => {
+ // this should be a callback to clear the contents of the slice container
+ $(selector).html(data);
+ },
+ css: (property, value) => {
+ $(selector).css(property, value);
+ },
+ height: getHeight,
+ show: () => { },
+ get: n => ($(selector).get(n)),
+ find: classname => ($(selector).find(classname)),
+ },
+ width: getWidth,
+ height: getHeight,
+ render_template: () => {},
+ setFilter: () => {},
+ getFilters: () => {},
+ addFilter: (col, val) => addFilter({ ...id_map[col], filter: val }),
+ removeFilter: col => removeFilter(id_map[col]),
+ done: () => {},
+ clearError: () => {},
+ error() {},
+ d3format: (col, number) => d3format((id_map[col] || {}).format, number),
+ data: {
+ csv_endpoint: getExploreUrl(formData, 'csv'),
+ json_endpoint: getExploreUrl(formData, 'json'),
+ standalone_endpoint: getExploreUrl(formData, 'standalone'),
+ },
+ };
+}
diff --git a/superset/assets/javascripts/swivel/index.jsx b/superset/assets/javascripts/swivel/index.jsx
new file mode 100644
index 000000000000..164b7cbc663f
--- /dev/null
+++ b/superset/assets/javascripts/swivel/index.jsx
@@ -0,0 +1,139 @@
+/* eslint camelcase: 0 */
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+// Reudx libs
+import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
+import { Provider } from 'react-redux';
+import { compressToBase64, decompressFromBase64 } from 'lz-string';
+import persistState from 'redux-localstorage';
+import thunk from 'redux-thunk';
+
+import { appSetup } from '../common';
+import { initJQueryAjax } from '../modules/utils';
+import { initEnhancer } from '../reduxUtils';
+
+import configureShortcuts from './shortcuts';
+
+import FormDataStore from './stores/FormDataStore';
+import { refDataReducer } from './reducers/refDataReducer';
+import { settingsReducer } from './reducers/settingsReducer';
+import { vizDataReducer } from './reducers/vizDataReducer';
+import { controlReducer } from './reducers/controlReducer';
+import { keyBindingsReducer } from './reducers/keyBindingsReducer';
+
+import { bootstrap } from './actions/querySettingsActions';
+import { LOCAL_STORAGE_KEY_PREFIX } from './constants';
+import { getSessionKey, updateSession } from './SessionManager';
+
+import ReduxContainer from './components/Container';
+import './main.css';
+
+const exploreViewContainer = document.getElementById('js-swivel-view-container');
+const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstrap'));
+
+let formData;
+if (bootstrapData.lz_form_data) {
+ formData = JSON.parse(decompressFromBase64(bootstrapData.lz_form_data));
+} else if (bootstrapData.form_data) {
+ formData = JSON.parse(bootstrapData.form_data);
+} else {
+ formData = {};
+}
+const bsFromData = new FormDataStore(formData);
+const session = getSessionKey(bootstrapData);
+const localStorageKey = `${LOCAL_STORAGE_KEY_PREFIX}${session}`;
+
+// If there is any JS error ask if local storage should be deleted
+// This will somewhat gracefully handle changes in the data model
+onerror = (message, file, line, column, errorObject) => {
+ // eslint-disable-next-line no-console
+ console.error({ message, file, line, column, errorObject });
+ if (localStorage.getItem(localStorageKey)) {
+ // eslint-disable-next-line no-alert
+ const r = confirm(`Error: ${message} \n\n Reset Swivel?`);
+ if (r) {
+ location.search = '?reset=true';
+ location.load();
+ return true;
+ }
+ return false;
+ }
+ return false;
+};
+
+
+appSetup();
+initJQueryAjax();
+
+const combinedReducer = combineReducers({
+ controls: controlReducer,
+ settings: settingsReducer,
+ refData: refDataReducer,
+ vizData: vizDataReducer,
+ keyBindings: keyBindingsReducer,
+});
+
+// This will serialize redux-undo states correctly as well as limit the
+// the scope of the state that will get persisted in localStorage
+function storageSlicer() {
+ return (state) => {
+ if (state.settings) {
+ return {
+ controls: state.controls,
+ settings: {
+ future: state.settings.future,
+ present: state.settings.present,
+ past: state.settings.past,
+ },
+ };
+ }
+ return {};
+ };
+}
+
+function storageDeserialize(...args) {
+ const newArgs = args;
+ if (newArgs.length && newArgs[0]) {
+ newArgs[0] = decompressFromBase64(newArgs[0]);
+ const state = JSON.parse(...newArgs);
+ if (state && 'settings' in state) {
+ state.settings.history = state.settings;
+ return state;
+ }
+ }
+ return null;
+}
+
+function storageSerialize(...args) {
+ if (args.length) {
+ updateSession(session, args[0].settings.present.viz.title);
+ }
+ return compressToBase64(JSON.stringify(...args));
+}
+
+const store = createStore(combinedReducer,
+ compose(applyMiddleware(thunk),
+ initEnhancer(false),
+ persistState(null, {
+ key: localStorageKey,
+ slicer: storageSlicer,
+ serialize: storageSerialize,
+ deserialize: storageDeserialize,
+ })),
+);
+
+const swivelViewContainer = document.getElementById('js-swivel-view-container');
+
+// Build initial state from bootstrap data
+store.dispatch(bootstrap(bsFromData));
+
+// Hook up global Keyboard shortcuts
+configureShortcuts(store);
+
+ReactDOM.render(
+
+
+
+ , swivelViewContainer,
+);
diff --git a/superset/assets/javascripts/swivel/listeners/QuerySettingsListener.js b/superset/assets/javascripts/swivel/listeners/QuerySettingsListener.js
new file mode 100644
index 000000000000..fe095d686c09
--- /dev/null
+++ b/superset/assets/javascripts/swivel/listeners/QuerySettingsListener.js
@@ -0,0 +1,59 @@
+import { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+import { updateFormDataAndRunQuery } from '../actions/querySettingsActions';
+import { setOutdated } from '../actions/vizDataActions';
+
+const propTypes = {
+ run: PropTypes.bool.isRequired,
+ settings: PropTypes.object.isRequired,
+
+ updateAndRun: PropTypes.func.isRequired,
+ handleOutdated: PropTypes.func.isRequired,
+};
+
+/**
+ * This renderless / headless component listens to the query state
+ * (anything that requires running a query)
+ * and will either run the query or mark the current data as outdated
+ */
+class QuerySettingsListener extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.update = this.update.bind(this);
+ }
+
+ componentDidMount(prevProps) { this.update(prevProps); }
+ componentDidUpdate() { this.update(); }
+
+ update(prevProps) {
+ const { run, settings, updateAndRun, handleOutdated } = this.props;
+ if (run && settings.datasource) {
+ updateAndRun(settings);
+ } else if (!prevProps || run !== prevProps.run) {
+ // don't mark chart outdated if only 'run' changed
+ handleOutdated();
+ }
+ }
+
+ render() { return null; }
+}
+
+QuerySettingsListener.propTypes = propTypes;
+
+const mapStateToProps = state => ({
+ settings: state.settings.present.query,
+ run: state.controls.run,
+});
+
+const mapDispatchToProps = dispatch => ({
+ updateAndRun: (settings) => {
+ dispatch(updateFormDataAndRunQuery(settings));
+ },
+ handleOutdated: () => {
+ dispatch(setOutdated(true));
+ },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(QuerySettingsListener);
diff --git a/superset/assets/javascripts/swivel/listeners/VizSettingsListener.jsx b/superset/assets/javascripts/swivel/listeners/VizSettingsListener.jsx
new file mode 100644
index 000000000000..2fc68d73354c
--- /dev/null
+++ b/superset/assets/javascripts/swivel/listeners/VizSettingsListener.jsx
@@ -0,0 +1,41 @@
+import { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { updateFormData } from '../actions/vizSettingsActions';
+
+const propTypes = {
+ vizSettings: PropTypes.object.isRequired,
+ updateFormData: PropTypes.func.isRequired,
+};
+
+/**
+ * This renderless / headless component listens to the visualization state
+ * (anything that requires that requires re-rendering the charts without
+ * running a query)
+ */
+class VizSettingsListener extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.update = this.update.bind(this);
+ }
+ componentDidMount() { this.update(); }
+ componentDidUpdate() { this.update(); }
+
+ update() {
+ this.props.updateFormData(this.props.vizSettings);
+ }
+
+ render() { return null; }
+}
+
+VizSettingsListener.propTypes = propTypes;
+
+const mapStateToProps = state => ({
+ vizSettings: state.settings.present.viz,
+});
+
+const mapDispatchToProps = dispatch => ({
+ updateFormData: vizSettings => dispatch(updateFormData(vizSettings)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(VizSettingsListener);
diff --git a/superset/assets/javascripts/swivel/main.css b/superset/assets/javascripts/swivel/main.css
new file mode 100644
index 000000000000..25df50f183b3
--- /dev/null
+++ b/superset/assets/javascripts/swivel/main.css
@@ -0,0 +1,81 @@
+.sidebar-container {
+ height: 35%;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ background-color: lightgrey;
+}
+
+.column-drop-bar {
+ padding: 0px 0px 0px 0px;
+ background-color: lightgrey;
+}
+
+.column-drop-bar .col-lg-1 {
+ position: relative;
+ min-height: 1px;
+ padding-left: 10px;
+ padding-right: 0px;
+}
+
+.column-drop-bar .list-group-item {
+ padding: 0px 0px 0px 0px;
+ min-height: 0px;
+}
+
+.column-drop-bar .btn {
+ white-space: normal;
+ padding: 5px 10px;
+}
+
+.column-drop-bar h4 {
+ font-size: 14px;
+ font-weight: bold;
+}
+
+.chart-title h4 {
+ font-size: 14px;
+}
+
+#popover-ok-button .btn {
+ float: right;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
+#run-tool-bar {
+ height: 30px;
+ margin-bottom: 1rem;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+}
+
+#run-tool-bar .btn {
+ height: 100%;
+}
+
+.btn-group {
+ display: inline-flex;
+ height: 100%;
+}
+
+.run-button .btn {
+ min-width: 72px;
+ height: 100%;
+}
+
+#run-button-mode {
+ min-width: 0px;
+}
+
+.run-button .caret {
+ display: table-row;
+}
+
+.sidebar-container .list-group-item {
+ padding: 5px 10px;
+}
+
+.label {
+ display: inline-block;
+}
diff --git a/superset/assets/javascripts/swivel/reducers/controlReducer.js b/superset/assets/javascripts/swivel/reducers/controlReducer.js
new file mode 100644
index 000000000000..a8716c19a6ac
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/controlReducer.js
@@ -0,0 +1,63 @@
+import * as actions from '../actions/globalActions';
+import ControlStore from '../stores/ControlStore';
+
+export const controlReducer = function (currentState = new ControlStore(), action) {
+ let state = currentState;
+ if (state.constructor.name !== ControlStore.name) {
+ state = new ControlStore(state);
+ }
+ const actionHandlers = {
+ [actions.RESET]() {
+ return new ControlStore();
+ },
+ [actions.SET_AUTO_RUN]() {
+ if (state.autoRun !== action.autoRun || state.run !== action.autoRun) {
+ return new ControlStore(state, {
+ autoRun: action.autoRun,
+ run: action.autoRun,
+ });
+ }
+ return state;
+ },
+ [actions.SET_RUN]() {
+ const run = action.run || state.autoRun;
+ if (state.run !== run) {
+ return new ControlStore(state, { run });
+ }
+ return state;
+ },
+ [actions.ABORT]() {
+ if (state.queryRequest &&
+ state.queryRequest.abort) {
+ state.queryRequest.abort();
+ }
+ return state;
+ },
+ [actions.SET_IS_RUNNING]() {
+ if (action.isRunning &&
+ state.queryRequest !== action.queryRequest &&
+ state.queryRequest &&
+ state.queryRequest.abort) {
+ state.queryRequest.abort();
+ }
+ return new ControlStore(state, {
+ isRunning: action.isRunning || state.queryRequest !== action.queryRequest,
+ queryRequest: action.isRunning ? action.queryRequest : state.queryRequest,
+ });
+ },
+ [actions.SET_ERROR]() {
+ if (!action.error !== !state.error) {
+ return new ControlStore(state, {
+ error: action.error,
+ run: state.autoRun,
+ });
+ }
+ return state;
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
+
diff --git a/superset/assets/javascripts/swivel/reducers/keyBindingsReducer.js b/superset/assets/javascripts/swivel/reducers/keyBindingsReducer.js
new file mode 100644
index 000000000000..68392b9501dc
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/keyBindingsReducer.js
@@ -0,0 +1,19 @@
+import * as actions from '../actions/keyBindingsActions';
+
+// You watch for the phase change to trigger on keybindings
+
+export const keyBindingsReducer = function (state = {}, action) {
+ const actionHandlers = {
+ [actions.SEARCH_COLUMNS]() {
+ return { ...state, columnSearchTrigger: !state.columnSearchTrigger };
+ },
+ [actions.SEARCH_METRICS]() {
+ return { ...state, metricSearchTrigger: !state.metricSearchTrigger };
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
+
diff --git a/superset/assets/javascripts/swivel/reducers/querySettingsReducer.js b/superset/assets/javascripts/swivel/reducers/querySettingsReducer.js
new file mode 100644
index 000000000000..2ca280a2feb5
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/querySettingsReducer.js
@@ -0,0 +1,200 @@
+import * as actions from '../actions/querySettingsActions';
+import * as global from '../actions/globalActions';
+
+import QuerySettingsStore from '../stores/QuerySettingsStore';
+import { importFormData } from '../formDataUtils/importQuerySettings';
+
+import ColumnTypes from '../ColumnTypes';
+
+function twoDigits(d) {
+ if (d >= 0 && d < 10) return '0' + d.toString();
+ if (d > -10 && d < 0) return '-0' + (-1 * d).toString();
+ return d.toString();
+}
+
+function toMysqlFormat(timestamp) {
+ return timestamp.getUTCFullYear() + '-' + twoDigits(1 + timestamp.getUTCMonth()) + '-' +
+ twoDigits(timestamp.getUTCDate()) + ' ' + twoDigits(timestamp.getUTCHours()) + ':' +
+ twoDigits(timestamp.getUTCMinutes()) + ':' + twoDigits(timestamp.getUTCSeconds());
+}
+
+
+export const querySettingsReducer = function (currentState = new QuerySettingsStore(), action) {
+ let state = currentState;
+ if (state.constructor.name !== QuerySettingsStore.name) {
+ state = new QuerySettingsStore(state);
+ }
+ const actionHandlers = {
+ [global.RESET]() {
+ return new QuerySettingsStore();
+ },
+ [global.IMPORT_FORM_DATA]() {
+ const newState = new QuerySettingsStore(state);
+ return importFormData(newState, action.formData, action.refData);
+ },
+ [actions.SET_DATASOURCE]() {
+ if (state.datasource === action.uid) {
+ return state;
+ }
+ return new QuerySettingsStore({ datasource: action.uid });
+ },
+ [actions.SET_VIZTYPE]() {
+ if (action.vizType === state.vizType) {
+ return state;
+ }
+ return state.getNextState({ vizType: action.vizType });
+ },
+ [actions.TOGGLE_METRIC]() {
+ const id = action.metric.id;
+ let orderBy = state.orderBy;
+ const metrics = Object.assign({}, state.metrics);
+ if (metrics[id]) {
+ delete metrics[id];
+ if (orderBy === id) {
+ orderBy = Object.keys(metrics).sort()[0];
+ }
+ } else {
+ metrics[id] = true;
+ if (!orderBy || orderBy === '') {
+ orderBy = id;
+ }
+ }
+ return state.getNextState({ metrics, orderBy });
+ },
+ [actions.CONFIGURE_FILTER]() {
+ const index = state.filters.findIndex(
+ i => i.id === action.filter.id,
+ );
+ const filters = state.filters.slice();
+ if (JSON.stringify(filters[index]) === JSON.stringify(action.filter)) {
+ return state;
+ }
+ filters[index] = action.filter;
+ return state.getNextState({ filters });
+ },
+ [actions.CONFIGURE_SPLIT]() {
+ const index = state.splits.findIndex(
+ i => i.id === action.split.id,
+ );
+ const prevSplit = state.splits[index];
+ const splitProps = { justAdded: false };
+ const stateProps = {};
+ if (prevSplit.columnType === ColumnTypes.TIMESTAMP) {
+ if (prevSplit.granularity === action.split.granularity) {
+ return state;
+ }
+
+ splitProps.granularity = action.split.granularity;
+ } else {
+ const { limit, orderBy, orderDesc } = action.split;
+ const parsedLimit = Number.parseInt(limit, 10) ? Number.parseInt(limit, 10) : null;
+ if (state.limit === parsedLimit &&
+ state.orderBy === orderBy &&
+ state.orderDesc === orderDesc
+ ) {
+ return state;
+ }
+ stateProps.limit = parsedLimit;
+ stateProps.orderBy = orderBy;
+ stateProps.orderDesc = orderDesc;
+ }
+ const splits = state.splits.slice();
+ splits[index] = Object.assign({}, prevSplit, splitProps);
+ return state.getNextState(stateProps, { splits });
+ },
+ [actions.REMOVE_FILTER]() {
+ const index = state.filters.findIndex(
+ i => i.id === action.filter.id,
+ );
+ if (index > -1) {
+ const filters = state.filters.slice();
+ filters.splice(index, 1);
+ return state.getNextState({ filters });
+ }
+ return state;
+ },
+ [actions.REMOVE_SPLIT]() {
+ const index = state.splits.findIndex(
+ i => i.id === action.split.id,
+ );
+ if (index > -1) {
+ const splits = state.splits.slice();
+ splits.splice(index, 1);
+ if (!splits.length) {
+ return state.getNextState({
+ splits,
+ limit: null,
+ orderBy: null,
+ orderDesc: true,
+ });
+ }
+ return state.getNextState({ splits });
+ }
+ return state;
+ },
+ [actions.ADD_FILTER]() {
+ const index = state.filters.findIndex(
+ i => i.id === action.filter.id,
+ );
+ if (index < 0 && action.filter) {
+ const filters = state.filters.slice();
+ filters.push(action.filter);
+ return state.getNextState({ filters });
+ }
+ return state;
+ },
+ [actions.ADD_SPLIT]() {
+ const index = state.splits.findIndex(
+ i => i.id === action.split.id,
+ );
+ if (index < 0 && action.split) {
+ const splits = state.splits.slice();
+ splits.push({
+ ...action.split,
+ justAdded: true,
+ });
+ return state.getNextState({ splits });
+ }
+ return state;
+ },
+ [actions.CHANGE_INTERVAL]() {
+ let start = new Date(action.intervalStart);
+ let end = new Date(action.intervalEnd);
+ if (action.intervalStart > action.intervalEnd) {
+ start = new Date(action.intervalEnd);
+ end = new Date(action.intervalStart);
+ }
+ const index = state.filters.findIndex(
+ x => x.columnType === ColumnTypes.TIMESTAMP,
+ );
+ if (index < 0) {
+ return state;
+ }
+ const filters = state.filters.slice();
+ filters[index] = Object.assign({}, filters[index],
+ {
+ intervalStart: toMysqlFormat(start),
+ intervalEnd: toMysqlFormat(end),
+ });
+ return state.getNextState({ filters });
+ },
+
+ [actions.SET_DEFAULTS]() {
+ const refData = action.refData;
+ if (refData.columns && !state.filters.length) {
+ state = querySettingsReducer(state, actions.addFilter(
+ refData.columns.find(x => x.columnType === ColumnTypes.TIMESTAMP)));
+ }
+ if (refData.metrics &&
+ refData.metrics.length &&
+ !Object.keys(state.metrics).length) {
+ state = querySettingsReducer(state, actions.toggleMetric(refData.metrics[0]));
+ }
+ return state;
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]() || state;
+ }
+ return state;
+};
diff --git a/superset/assets/javascripts/swivel/reducers/refDataReducer.js b/superset/assets/javascripts/swivel/reducers/refDataReducer.js
new file mode 100644
index 000000000000..fe2fbf8382d8
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/refDataReducer.js
@@ -0,0 +1,28 @@
+import * as global from '../actions/globalActions';
+import * as actions from '../actions/refDataActions';
+
+import RefDataStore from '../stores/RefDataStore';
+
+export const refDataReducer = function (state = new RefDataStore(), action) {
+ const actionHandlers = {
+ [global.RESET]() {
+ return new RefDataStore({ datasources: state.datasources });
+ },
+ [actions.SET_COLUMNS]() {
+ return Object.assign({}, state, { columns: action.columns });
+ },
+ [actions.SET_METRICS]() {
+ return Object.assign({}, state, { metrics: action.metrics });
+ },
+ [actions.SET_TIME_GRAINS]() {
+ return Object.assign({}, state, { timeGrains: action.timeGrains });
+ },
+ [actions.SET_DATASOURCES]() {
+ return Object.assign({}, state, { datasources: action.datasources });
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
diff --git a/superset/assets/javascripts/swivel/reducers/settingsReducer.js b/superset/assets/javascripts/swivel/reducers/settingsReducer.js
new file mode 100644
index 000000000000..918ace08b78f
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/settingsReducer.js
@@ -0,0 +1,40 @@
+import { combineReducers } from 'redux';
+import undoable from 'redux-undo';
+
+import { querySettingsReducer } from './querySettingsReducer';
+import { vizSettingsReducer } from './vizSettingsReducer';
+
+import * as global from '../actions/globalActions';
+import * as queryActions from '../actions/querySettingsActions';
+
+// This function is used to keep the undo history clean.
+// Changes to the ERROR state should be ignored. The "CONFIGURE_SPLIT"
+// logic is required since adding a split is a 2 step process
+// (ADD and CONFIGURE) without user interaction for the initial configuration,
+// which therefore should not be added to the UNDO history.
+export function includeInUndoHistory() {
+ return (action, currentState, previousState) => {
+ if (currentState === previousState) {
+ return false;
+ } else if (action.type === global.CLEAR_HISTORY) {
+ return false;
+ } else if (action.type === queryActions.CONFIGURE_SPLIT) {
+ const oldSplit = previousState.query.splits.find(x => x.id === action.split.id);
+ if (oldSplit && oldSplit.justAdded) {
+ return false;
+ }
+ }
+ return true;
+ };
+}
+
+// Overriding the default initTypes (e.g. NOOP) will prevent the history to
+// be reset on reload (or opening a new tab)
+export const settingsReducer = undoable(
+ combineReducers({ query: querySettingsReducer, viz: vizSettingsReducer }),
+ {
+ filter: includeInUndoHistory(),
+ initTypes: [global.CLEAR_HISTORY],
+ limit: 50,
+ });
+
diff --git a/superset/assets/javascripts/swivel/reducers/vizDataReducer.js b/superset/assets/javascripts/swivel/reducers/vizDataReducer.js
new file mode 100644
index 000000000000..07289fd2d2d5
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/vizDataReducer.js
@@ -0,0 +1,33 @@
+import VizDataStore from '../stores/VizDataStore';
+import FormDataStore from '../stores/FormDataStore';
+import * as global from '../actions/globalActions';
+import * as actions from '../actions/vizDataActions';
+
+export const vizDataReducer = function (state = new VizDataStore(), action) {
+ const actionHandlers = {
+ [global.RESET]() {
+ return new VizDataStore();
+ },
+ [actions.SET_OUTDATED]() {
+ return new VizDataStore(state, { outdated: action.outdated });
+ },
+ [actions.SET_DATA]() {
+ return new VizDataStore(state, { data: action.data });
+ },
+ [actions.RESET_DATA]() {
+ return new VizDataStore(state, { data: null });
+ },
+ [global.UPDATE_FORM_DATA]() {
+ const formData = new FormDataStore(state.formData, action.formData);
+ if (action.wipeData) {
+ return new VizDataStore(state, { data: null, formData });
+ }
+ return new VizDataStore(state, { formData });
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
+
diff --git a/superset/assets/javascripts/swivel/reducers/vizSettingsReducer.js b/superset/assets/javascripts/swivel/reducers/vizSettingsReducer.js
new file mode 100644
index 000000000000..fdabe4008ae2
--- /dev/null
+++ b/superset/assets/javascripts/swivel/reducers/vizSettingsReducer.js
@@ -0,0 +1,34 @@
+import VizSettingsStore from '../stores/VizSettingsStore';
+import * as global from '../actions/globalActions';
+import * as actions from '../actions/vizSettingsActions';
+import { importFormData } from '../formDataUtils/importVizSettings';
+import { SET_DATASOURCE } from '../actions/querySettingsActions';
+
+export const vizSettingsReducer = function (state = new VizSettingsStore(), action) {
+ const actionHandlers = {
+ [global.RESET]() {
+ return new VizSettingsStore();
+ },
+ [global.IMPORT_FORM_DATA]() {
+ const newState = new VizSettingsStore(state);
+ return importFormData(newState, action.formData, action.refData);
+ },
+ [actions.TOGGLE_SHOW_LEGEND]() {
+ return new VizSettingsStore(state, { showLegend: !state.showLegend });
+ },
+ [actions.TOGGLE_RICH_TOOLTIP]() {
+ return new VizSettingsStore(state, { richTooltip: !state.richTooltip });
+ },
+ [actions.TOGGLE_SEPARATE_CHARTS]() {
+ return new VizSettingsStore(state, { separateCharts: !state.separateCharts });
+ },
+ [SET_DATASOURCE]() {
+ return new VizSettingsStore(state, { title: action.name });
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+};
+
diff --git a/superset/assets/javascripts/swivel/shortcuts.js b/superset/assets/javascripts/swivel/shortcuts.js
new file mode 100644
index 000000000000..63e1dffc0d82
--- /dev/null
+++ b/superset/assets/javascripts/swivel/shortcuts.js
@@ -0,0 +1,61 @@
+import $ from 'jquery';
+import { ActionCreators } from 'redux-undo';
+import { searchColumns, searchMetrics } from './actions/keyBindingsActions';
+
+export default function configureShortcuts(store) {
+ const keyMap = [
+ // Undo cmd + z
+ {
+ keyCode: 90,
+ metaKey: true,
+ shiftKey: false,
+ altKey: false,
+ ctrlKey: false,
+ f: () => store.dispatch(ActionCreators.undo()),
+ },
+ // Redo cmd + shift + z
+ {
+ keyCode: 90,
+ metaKey: true,
+ shiftKey: true,
+ altKey: false,
+ ctrlKey: false,
+ f: () => store.dispatch(ActionCreators.redo()),
+ },
+ // Open Column Search ctrl + c
+ {
+ keyCode: 67,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ ctrlKey: true,
+ f: () => store.dispatch(searchColumns()),
+ },
+ // Open Metric Search ctrl + m
+ {
+ keyCode: 77,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ ctrlKey: true,
+ f: () => store.dispatch(searchMetrics()),
+ },
+ ];
+
+ function hashKey(x) {
+ return `${x.keyCode}${x.metaKey}${x.shiftKey}${x.altKey}${x.ctrlKey}`;
+ }
+
+ const keyLookup = keyMap.reduce((lookup, k) => ({
+ ...lookup,
+ [hashKey(k)]: k.f,
+ }), {});
+
+ $(':root').keydown((x) => {
+ const f = keyLookup[hashKey(x)];
+ if (f) {
+ return f();
+ }
+ return true;
+ });
+}
diff --git a/superset/assets/javascripts/swivel/stores/ControlStore.js b/superset/assets/javascripts/swivel/stores/ControlStore.js
new file mode 100644
index 000000000000..e5f610232286
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/ControlStore.js
@@ -0,0 +1,19 @@
+export default class ControlStore {
+ constructor(...data) {
+ // Should queries be run automatically
+ this.autoRun = true;
+
+ // Schedule a run
+ this.run = true;
+
+ // Is query running
+ this.isRunning = false;
+ this.error = null;
+ this.queryRequest = {};
+ this.update(...data);
+ }
+
+ update(...data) {
+ Object.assign(this, ...data);
+ }
+}
diff --git a/superset/assets/javascripts/swivel/stores/FormDataStore.js b/superset/assets/javascripts/swivel/stores/FormDataStore.js
new file mode 100644
index 000000000000..221dc91cd3bd
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/FormDataStore.js
@@ -0,0 +1,147 @@
+
+// import controls from '../explore/stores/controls';
+// Object.keys(controls).sort().forEach(x => console.log("this." + x + " = null;"))
+export default class FormDataStore {
+ constructor(...data) {
+ this.all_columns = null;
+ this.all_columns_x = null;
+ this.all_columns_y = null;
+ this.bar_stacked = null;
+ this.bottom_margin = null;
+ this.cache_timeout = null;
+ this.canvas_image_rendering = null;
+ this.charge = null;
+ this.clustering_radius = null;
+ this.code = null;
+ this.color_scheme = null;
+ this.columns = null;
+ this.combine_metric = null;
+ this.compare_lag = null;
+ this.compare_suffix = null;
+ this.contribution = null;
+ this.country_fieldtype = null;
+ this.datasource = null;
+ this.date_filter = null;
+ this.domain_granularity = null;
+ this.donut = null;
+ this.druid_time_origin = null;
+ this.entity = null;
+ this.filters = null;
+ this.global_opacity = null;
+ this.granularity = null;
+ this.granularity_sqla = null;
+ this.groupby = null;
+ this.having = null;
+ this.having_filters = null;
+ this.horizon_color_scale = null;
+ this.include_search = null;
+ this.include_series = null;
+ this.include_time = null;
+ this.instant_filtering = null;
+ this.labels_outside = null;
+ this.left_margin = null;
+ this.limit = null;
+ this.line_interpolation = null;
+ this.linear_color_scheme = null;
+ this.link_length = null;
+ this.mapbox_color = null;
+ this.mapbox_label = null;
+ this.mapbox_style = null;
+ this.marker_labels = null;
+ this.marker_line_labels = null;
+ this.marker_lines = null;
+ this.markers = null;
+ this.markup_type = null;
+ this.max_bubble_size = null;
+ this.metric = null;
+ this.metric_2 = null;
+ this.metrics = null;
+ this.min_leaf_node_event_count = null;
+ this.normalize_across = null;
+ this.num_period_compare = null;
+ this.number_format = null;
+ this.offset_overlays = null;
+ this.order_bars = null;
+ this.order_by_cols = null;
+ this.order_by_entity = null;
+ this.overlays = null;
+ this.page_length = null;
+ this.pandas_aggfunc = null;
+ this.period_ratio_type = null;
+ this.pie_label_type = null;
+ this.pivot_margins = null;
+ this.point_radius = null;
+ this.point_radius_unit = null;
+ this.range_labels = null;
+ this.ranges = null;
+ this.reduce_x_ticks = null;
+ this.render_while_dragging = null;
+ this.resample_fillmethod = null;
+ this.resample_how = null;
+ this.resample_rule = null;
+ this.rich_tooltip = null;
+ this.rolling_periods = null;
+ this.rolling_type = null;
+ this.rotation = null;
+ this.row_limit = null;
+ this.secondary_metric = null;
+ this.select_country = null;
+ this.series = null;
+ this.series_height = null;
+ this.show_bar_value = null;
+ this.show_brush = null;
+ this.show_bubbles = null;
+ this.show_controls = null;
+ this.show_datatable = null;
+ this.show_legend = null;
+ this.show_markers = null;
+ this.since = null;
+ this.size = null;
+ this.size_from = null;
+ this.size_to = null;
+ this.slice_id = null;
+ this.stacked_style = null;
+ this.subdomain_granularity = null;
+ this.subheader = null;
+ this.table_filter = null;
+ this.table_timestamp_format = null;
+ this.time_compare = null;
+ this.time_grain_sqla = null;
+ this.timeseries_limit_metric = null;
+ this.treemap_ratio = null;
+ this.until = null;
+ this.url = null;
+ this.viewport_latitude = null;
+ this.viewport_longitude = null;
+ this.viewport_zoom = null;
+ this.viz_type = null;
+ this.where = null;
+ this.whisker_options = null;
+ this.x = null;
+ this.x_axis_format = null;
+ this.x_axis_label = null;
+ this.x_axis_showminmax = null;
+ this.x_axis_time_format = null;
+ this.x_log_scale = null;
+ this.xscale_interval = null;
+ this.y = null;
+ this.y_axis_2_format = null;
+ this.y_axis_bounds = null;
+ this.y_axis_format = null;
+ this.y_axis_label = null;
+ this.y_axis_showminmax = null;
+ this.y_log_scale = null;
+ this.yscale_interval = null;
+
+ this.error = null;
+ this.update(...data);
+ }
+
+ update(...data) {
+ Object.assign(this, ...data);
+ }
+
+ toJson() {
+ return JSON.stringify(this, (k, v) => v === null ? undefined : v);
+ }
+}
diff --git a/superset/assets/javascripts/swivel/stores/QuerySettingsStore.js b/superset/assets/javascripts/swivel/stores/QuerySettingsStore.js
new file mode 100644
index 000000000000..8167fbcf4dc9
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/QuerySettingsStore.js
@@ -0,0 +1,27 @@
+export default class QuerySettingsStore {
+ constructor(...data) {
+ // Query related settings
+ this.filters = [];
+ this.splits = [];
+ this.limit = null;
+ this.orderBy = null;
+ this.orderDesc = true;
+ this.datasource = '';
+ this.metrics = {};
+
+ // TODO these should be in VizSettingsStore
+ this.vizType = 'table';
+
+ this.update(...data);
+ }
+
+ update(...data) {
+ Object.assign(this, ...data);
+ }
+
+ getNextState(...args) {
+ const updates = Object.assign({}, ...args);
+ // TODO we should do full state validation here
+ return new QuerySettingsStore(this, updates);
+ }
+}
diff --git a/superset/assets/javascripts/swivel/stores/RefDataStore.js b/superset/assets/javascripts/swivel/stores/RefDataStore.js
new file mode 100644
index 000000000000..ec6fd3dcacde
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/RefDataStore.js
@@ -0,0 +1,21 @@
+export default class RefDataStore {
+ constructor(...data) {
+ // Dynamic ref data
+ this.datasources = [];
+ this.columns = [];
+ this.metrics = [];
+ this.timeGrains = [];
+ // Static ref data
+ this.viz_types = [
+ { id: 'table', name: 'Table' },
+ { id: 'line', name: 'Line Chart' },
+ { id: 'bar', name: 'Bar Chart' },
+ { id: 'area', name: 'Area Chart' },
+ ];
+ this.update(...data);
+ }
+
+ update(...data) {
+ Object.assign(this, ...data);
+ }
+}
diff --git a/superset/assets/javascripts/swivel/stores/VizDataStore.js b/superset/assets/javascripts/swivel/stores/VizDataStore.js
new file mode 100644
index 000000000000..eec879bc9b80
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/VizDataStore.js
@@ -0,0 +1,12 @@
+export default class VizDataStore {
+ constructor(...args) {
+ this.data = null;
+ this.formData = {};
+ this.outdated = false;
+ this.update(...args);
+ }
+
+ update(...args) {
+ Object.assign(this, ...args);
+ }
+}
diff --git a/superset/assets/javascripts/swivel/stores/VizSettingsStore.js b/superset/assets/javascripts/swivel/stores/VizSettingsStore.js
new file mode 100644
index 000000000000..2f54aa22a641
--- /dev/null
+++ b/superset/assets/javascripts/swivel/stores/VizSettingsStore.js
@@ -0,0 +1,14 @@
+export default class VizSettingsStore {
+ constructor(...data) {
+ this.title = '';
+ this.showLegend = false;
+ this.richTooltip = true;
+ this.separateCharts = false;
+
+ this.update(...data);
+ }
+
+ update(...data) {
+ Object.assign(this, ...data);
+ }
+}
diff --git a/superset/assets/package.json b/superset/assets/package.json
index c944ad2fa0bf..c581d0fd9dbd 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -59,6 +59,7 @@
"deck.gl": "^5.0.1",
"distributions": "^1.0.0",
"dompurify": "^1.0.3",
+ "escape-string-regexp": "^1.0.5",
"fastdom": "^1.0.6",
"geolib": "^2.0.24",
"immutable": "^3.8.2",
@@ -66,6 +67,7 @@
"jquery": "3.1.1",
"lodash.throttle": "^4.1.1",
"luma.gl": "^5.0.1",
+ "lz-string": "^1.4.4",
"mapbox-gl": "^0.43.0",
"mathjs": "^3.16.3",
"moment": "2.18.1",
@@ -81,7 +83,9 @@
"react-bootstrap": "^0.31.5",
"react-bootstrap-table": "^4.0.2",
"react-color": "^2.13.8",
- "react-datetime": "2.9.0",
+ "react-datetime": "^2.9.0",
+ "react-dnd": "^2.4.0",
+ "react-dnd-html5-backend": "^2.4.1",
"react-dom": "^15.6.2",
"react-gravatar": "^2.6.1",
"react-grid-layout": "^0.16.0",
@@ -99,15 +103,19 @@
"redux": "^3.5.2",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
+ "redux-undo": "^0.6.1",
"shortid": "^2.2.6",
"sprintf-js": "^1.1.1",
"srcdoc-polyfill": "^1.0.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"underscore": "^1.8.3",
"urijs": "^1.18.10",
+ "uuid": "^3.1.0",
"viewport-mercator-project": "^2.1.0"
},
"devDependencies": {
+ "ajv": "^5.2.2",
+ "ajv-keywords": "^2.1.0",
"babel-cli": "^6.14.0",
"babel-core": "^6.10.4",
"babel-istanbul": "^0.12.2",
diff --git a/superset/assets/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx b/superset/assets/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
index 506dd23f97be..0643eba0090b 100644
--- a/superset/assets/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/ExploreActionButtons_spec.jsx
@@ -14,6 +14,7 @@ describe('ExploreActionButtons', () => {
json_endpoint: '',
},
},
+ queryResponse: {},
queryEndpoint: 'localhost',
};
diff --git a/superset/assets/spec/javascripts/swivel/formDataUtils/data.js b/superset/assets/spec/javascripts/swivel/formDataUtils/data.js
new file mode 100644
index 000000000000..4025203e682a
--- /dev/null
+++ b/superset/assets/spec/javascripts/swivel/formDataUtils/data.js
@@ -0,0 +1,263 @@
+import QuerySettingsStore from '../../../../javascripts/swivel/stores/QuerySettingsStore';
+import ColumnTypes from '../../../../javascripts/swivel/ColumnTypes';
+
+export const QUERY_SETTINGS = new QuerySettingsStore({
+ filters: [
+ { id: 'ds',
+ name: 'ds',
+ columnType: 'TIMESTAMP',
+ groupable: false,
+ intervalStart: '100 years ago',
+ intervalEnd: 'now',
+ }],
+ splits: [
+ { name: 'ds',
+ id: 'ds',
+ columnType: 'TIMESTAMP',
+ groupable: false,
+ granularity: 'month',
+ }],
+ metrics: { count: true, avg__num: true },
+ vizType: 'line',
+ limit: 5,
+ orderBy: 'avg__num',
+ orderDesc: true,
+});
+
+export const QUERY_SETTINGS_FILTERS = [
+ {
+ id: 'equal_num',
+ name: 'equal_num',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: true,
+ filter: [
+ '1',
+ ],
+ invert: false,
+ like: false,
+ },
+ {
+ id: 'not_equal_num',
+ name: 'not_equal_num',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: true,
+ filter: [
+ '1',
+ ],
+ invert: true,
+ like: false,
+ },
+ {
+ id: 'equal_str',
+ name: 'equal_str',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'boy',
+ ],
+ invert: false,
+ like: false,
+ },
+ {
+ id: 'not_equal_str',
+ name: 'not_equal_str',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'boy',
+ ],
+ invert: true,
+ like: false,
+ },
+ {
+ id: 'like',
+ name: 'like',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'boy',
+ ],
+ invert: false,
+ like: true,
+ },
+ {
+ id: 'not_like',
+ name: 'not_like',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'boy',
+ ],
+ invert: true,
+ like: true,
+ },
+ {
+ id: 'in',
+ name: 'in',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'Aaron',
+ 'Dana',
+ ],
+ invert: false,
+ },
+ {
+ id: 'not_in',
+ name: 'not_in',
+ columnType: ColumnTypes.STRING,
+ groupable: true,
+ filter: [
+ 'Aaron',
+ 'Dana',
+ ],
+ invert: true,
+ },
+ {
+ id: 'less_then',
+ name: 'less_then',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: false,
+ rightOpen: true,
+ intervalStart: '',
+ intervalEnd: '600',
+ },
+ {
+ id: 'less_eq_then',
+ name: 'less_eq_then',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: false,
+ rightOpen: false,
+ intervalStart: '',
+ intervalEnd: '600',
+ },
+ {
+ id: 'greater_then',
+ name: 'greater_then',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: true,
+ rightOpen: false,
+ intervalStart: '500',
+ intervalEnd: '',
+ },
+ {
+ id: 'greater_eq_then',
+ name: 'greater_eq_then',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: false,
+ rightOpen: false,
+ intervalStart: '500',
+ intervalEnd: '',
+ },
+ {
+ id: 'in_between_open',
+ name: 'in_between_open',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: true,
+ rightOpen: true,
+ intervalStart: '500',
+ intervalEnd: '600',
+ },
+ {
+ id: 'in_between_closed',
+ name: 'in_between_closed',
+ columnType: ColumnTypes.NUMERIC,
+ groupable: false,
+ leftOpen: false,
+ rightOpen: false,
+ intervalStart: '500',
+ intervalEnd: '600',
+ },
+];
+
+export const QUERY_SETTINGS_GROUPBYS = [
+ {
+ name: 'gender',
+ id: 'gender',
+ columnType: 'NVARCHAR',
+ groupable: true,
+ },
+ {
+ name: 'name',
+ id: 'name',
+ columnType: 'NVARCHAR',
+ groupable: true,
+ },
+];
+
+export const FORM_DATA_TIME = {
+ until: 'now',
+ since: '100 years ago',
+ include_time: true,
+};
+
+export const FORM_DATA_GENERAL = {
+ order_desc: true,
+ limit: 5,
+ timeseries_limit_metric: 'avg__num',
+ metrics: ['count', 'avg__num'],
+ viz_type: 'line',
+};
+
+export const FORM_DATA_SQL = {
+ ...FORM_DATA_GENERAL,
+ ...FORM_DATA_TIME,
+ datasource: '3__table',
+ granularity_sqla: 'ds',
+ time_grain_sqla: 'month',
+};
+
+export const FORM_DATA_DRUID = {
+ ...FORM_DATA_GENERAL,
+ ...FORM_DATA_TIME,
+ datasource: '3__druid',
+ granularity: 'month',
+};
+
+export const FORM_DATA_FILTER_EQUAL_NUM = { col: 'equal_num', op: '==', val: '1' };
+export const FORM_DATA_FILTER_NOT_EQUAL_NUM = { col: 'not_equal_num', op: '!=', val: '1' };
+
+
+export const FORM_DATA_FILTER_EQUAL_STR = { col: 'equal_str', op: '==', val: 'boy' };
+export const FORM_DATA_FILTER_NOT_EQUAL_STR = { col: 'not_equal_str', op: '!=', val: 'boy' };
+
+export const FORM_DATA_FILTER_LIKE = { col: 'like', op: 'like', val: 'boy' };
+export const FORM_DATA_FILTER_NOT_LIKE = { col: 'not_like', op: 'not like', val: 'boy' };
+
+export const FORM_DATA_FILTER_IN = { col: 'in', op: 'in', val: ['Aaron', 'Dana'] };
+export const FORM_DATA_FILTER_NOT_IN = { col: 'not_in', op: 'not in', val: ['Aaron', 'Dana'] };
+
+export const FORM_DATA_FILTER_LESS_THEN = { col: 'less_then', op: '<', val: '600' };
+export const FORM_DATA_FILTER_LESS_EQ_THEN = { col: 'less_eq_then', op: '<=', val: '600' };
+
+export const FORM_DATA_FILTER_GREATER_THEN = { col: 'greater_then', op: '>', val: '500' };
+export const FORM_DATA_FILTER_GREATER_EQ_THEN = { col: 'greater_eq_then', op: '>=', val: '500' };
+
+export const FORM_DATA_FILTER_REGEX = { col: 'like', op: 'regex', val: 'boy' };
+
+export const FORM_DATA_FILTER_IN_BETWEEN_OPEN = [
+ { col: 'in_between_open', op: '>', val: '500' },
+ { col: 'in_between_open', op: '<', val: '600' }];
+export const FORM_DATA_FILTER_IN_BETWEEN_CLOSED = [
+ { col: 'in_between_closed', op: '>=', val: '500' },
+ { col: 'in_between_closed', op: '<=', val: '600' }];
+
+
+export const FORM_DATA_GROUPBY = ['name', 'gender'];
+
+const columns = QUERY_SETTINGS.splits
+ .concat(QUERY_SETTINGS_FILTERS)
+ .concat(QUERY_SETTINGS_GROUPBYS);
+
+export const REF_DATA = { columns: columns.map(x => ({
+ name: x.name,
+ id: x.id,
+ columnType: x.columnType,
+ groupable: !!x.groupable,
+})) };
+
diff --git a/superset/assets/spec/javascripts/swivel/formDataUtils/formDataToQuery_spec.js b/superset/assets/spec/javascripts/swivel/formDataUtils/formDataToQuery_spec.js
new file mode 100644
index 000000000000..bbddaedae4bd
--- /dev/null
+++ b/superset/assets/spec/javascripts/swivel/formDataUtils/formDataToQuery_spec.js
@@ -0,0 +1,76 @@
+import 'mocha';
+import { expect, assert } from 'chai';
+
+import QuerySettingsStore from '../../../../javascripts/swivel/stores/QuerySettingsStore';
+import * as data from './data';
+
+
+import { importFormData } from '../../../../javascripts/swivel/formDataUtils/importQuerySettings';
+
+describe('FormData Converter To QuerySettings', () => {
+ describe('Convert Time ', () => {
+ it('converts time sql', () => {
+ const formData = data.FORM_DATA_SQL;
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.filters).to.have.deep.members(data.QUERY_SETTINGS.filters);
+ expect(settings.splits).to.have.deep.members(data.QUERY_SETTINGS.splits);
+ });
+ it('converts time druid', () => {
+ const formData = data.FORM_DATA_DRUID;
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.filters).to.have.deep.members(data.QUERY_SETTINGS.filters);
+ expect(settings.splits).to.have.deep.members(data.QUERY_SETTINGS.splits);
+ });
+ });
+ describe('General', () => {
+ it('Genearal with time split', () => {
+ const formData = data.FORM_DATA_SQL;
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ assert.equal(settings.orderDesc, formData.order_desc);
+ assert.equal(settings.orderBy, formData.timeseries_limit_metric);
+ assert.equal(settings.limit, formData.limit);
+ });
+ it('GroupBys', () => {
+ const formData = data.FORM_DATA_SQL;
+ formData.groupby = data.FORM_DATA_GROUPBY;
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.splits).to.contain.deep.members(data.QUERY_SETTINGS_GROUPBYS);
+ });
+ it('Metrics', () => {
+ const formData = data.FORM_DATA_SQL;
+ formData.groupby = data.FORM_DATA_GROUPBY;
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.metrics).to.include(data.QUERY_SETTINGS.metrics);
+ });
+ });
+ describe('Convert Filters', () => {
+ it('SQL Filters', () => {
+ const formData = data.FORM_DATA_SQL;
+ formData.filters = [
+ data.FORM_DATA_FILTER_EQUAL_NUM, data.FORM_DATA_FILTER_NOT_EQUAL_NUM,
+ data.FORM_DATA_FILTER_EQUAL_STR, data.FORM_DATA_FILTER_NOT_EQUAL_STR,
+ data.FORM_DATA_FILTER_LIKE, data.FORM_DATA_FILTER_NOT_LIKE,
+ data.FORM_DATA_FILTER_IN, data.FORM_DATA_FILTER_NOT_IN,
+ data.FORM_DATA_FILTER_LESS_THEN, data.FORM_DATA_FILTER_LESS_EQ_THEN,
+ data.FORM_DATA_FILTER_GREATER_THEN, data.FORM_DATA_FILTER_GREATER_EQ_THEN,
+ ];
+ formData.filters.push(...data.FORM_DATA_FILTER_IN_BETWEEN_OPEN);
+ formData.filters.push(...data.FORM_DATA_FILTER_IN_BETWEEN_CLOSED);
+
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.filters).to.have.deep.members(
+ data.QUERY_SETTINGS.filters.concat(data.QUERY_SETTINGS_FILTERS));
+ });
+
+ it('Druid Filters', () => {
+ const formData = data.FORM_DATA_DRUID;
+ formData.filters = [
+ data.FORM_DATA_FILTER_REGEX,
+ ];
+ const settings = importFormData(new QuerySettingsStore(), formData, data.REF_DATA);
+ expect(settings.filters).to.have.deep.members(
+ data.QUERY_SETTINGS.filters.concat(data.QUERY_SETTINGS_FILTERS.filter(x => x.id === 'like')),
+ );
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/swivel/formDataUtils/queryToFormData_spec.js b/superset/assets/spec/javascripts/swivel/formDataUtils/queryToFormData_spec.js
new file mode 100644
index 000000000000..aee540928930
--- /dev/null
+++ b/superset/assets/spec/javascripts/swivel/formDataUtils/queryToFormData_spec.js
@@ -0,0 +1,113 @@
+import 'mocha';
+import { expect, assert } from 'chai';
+import * as data from './data';
+
+import { convertQuerySettingsToFormData } from '../../../../javascripts/swivel/formDataUtils/convertToFormData';
+
+
+describe('QuerySettings to FormData Converter', () => {
+ describe('Convert Time ', () => {
+ it('converts time sql', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__table' });
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData).to.deep.include(data.FORM_DATA_TIME);
+ assert.ok(!formData.granularity);
+ assert.equal(formData.granularity_sqla, 'ds');
+ assert.equal(formData.time_grain_sqla, 'month');
+ });
+ it('converts time druid', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__druid' });
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData).to.deep.include(data.FORM_DATA_TIME);
+ assert.equal(formData.granularity, 'month');
+ assert.ok(!formData.granularity_sqla);
+ assert.ok(!formData.time_grain_sqla);
+ });
+ });
+ describe('General', () => {
+ it('Genearal with time split', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__table' });
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData).to.deep.include(data.FORM_DATA_GENERAL);
+ });
+ it('Genearal without time split', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({
+ datasource: '3__table',
+ splits: [],
+ });
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData).to.deep.include({ row_limit: 5 });
+ });
+ it('GroupBys', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__table' });
+ settings.splits = data.QUERY_SETTINGS_GROUPBYS;
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData.groupby).to.have.members(data.FORM_DATA_GROUPBY);
+ });
+ it('Metrics', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__table' });
+ const formData = convertQuerySettingsToFormData(settings);
+ expect(formData.metrics).to.have.members(Object.keys(settings.metrics));
+ });
+ });
+ describe('Convert Filters', () => {
+ it('SQL Filters', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__table' });
+ settings.filters.push(...data.QUERY_SETTINGS_FILTERS);
+ const formData = convertQuerySettingsToFormData(settings);
+ const formDataFilters = formData.filters.reduce((lookup, f) => {
+ if (lookup[f.col]) {
+ lookup[f.col].push(f);
+ return lookup;
+ }
+ return { ...lookup, [f.col]: [f] };
+ }, {});
+
+ const testsSingle = [
+ ['equal_num', data.FORM_DATA_FILTER_EQUAL_NUM],
+ ['not_equal_num', data.FORM_DATA_FILTER_NOT_EQUAL_NUM],
+ ['equal_str', data.FORM_DATA_FILTER_EQUAL_STR],
+ ['not_equal_str', data.FORM_DATA_FILTER_NOT_EQUAL_STR],
+ ['like', data.FORM_DATA_FILTER_LIKE],
+ ['not_like', data.FORM_DATA_FILTER_NOT_LIKE],
+ ['in', data.FORM_DATA_FILTER_IN],
+ ['not_in', data.FORM_DATA_FILTER_NOT_IN],
+ ['less_then', data.FORM_DATA_FILTER_LESS_THEN],
+ ['less_eq_then', data.FORM_DATA_FILTER_LESS_EQ_THEN],
+ ['greater_then', data.FORM_DATA_FILTER_GREATER_THEN],
+ ['greater_eq_then', data.FORM_DATA_FILTER_GREATER_EQ_THEN],
+ ];
+ for (const t of testsSingle) {
+ expect(t[1]).to.deep.include(formDataFilters[t[0]][0], t[0]);
+ }
+ const testsInterval = [
+ ['in_between_open', data.FORM_DATA_FILTER_IN_BETWEEN_OPEN],
+ ['in_between_closed', data.FORM_DATA_FILTER_IN_BETWEEN_CLOSED],
+ ];
+ for (const t of testsInterval) {
+ expect(t[1]).to.have.deep.members(formDataFilters[t[0]], t[0]);
+ }
+ });
+
+ it('Druid Filters', () => {
+ const settings = data.QUERY_SETTINGS.getNextState({ datasource: '3__druid' });
+ settings.filters.push(...data.QUERY_SETTINGS_FILTERS);
+ const formData = convertQuerySettingsToFormData(settings);
+ const formDataFilters = formData.filters.reduce((lookup, f) => {
+ if (lookup[f.col]) {
+ lookup[f.col].push(f);
+ return lookup;
+ }
+ return { ...lookup, [f.col]: [f] };
+ }, {});
+
+ // TODO Implement REGEX
+ const testsSingle = [
+ ['like', data.FORM_DATA_FILTER_REGEX],
+ ];
+ for (const t of testsSingle) {
+ expect(t[1]).to.deep.include(formDataFilters[t[0]][0], t[0]);
+ }
+ });
+ });
+});
diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js
index b60c32a0e57c..a7f335edf3b4 100644
--- a/superset/assets/visualizations/nvd3_vis.js
+++ b/superset/assets/visualizations/nvd3_vis.js
@@ -95,9 +95,21 @@ function formatLabel(column, verbose_map) {
}
/* eslint-enable camelcase */
+// Calculates the longest label size for stretching bottom margin
+function calculateStretchMargins(data) {
+ const pixelsPerCharX = 4.5; // approx, depends on font size
+ let maxLabelSize = 10; // accommodate for shorter labels
+ data.forEach((d) => {
+ const axisLabels = d.values;
+ for (let i = 0; i < axisLabels.length; i++) {
+ maxLabelSize = Math.max(axisLabels[i].x.toString().length, maxLabelSize);
+ }
+ });
+ return Math.ceil(pixelsPerCharX * maxLabelSize);
+}
+
function nvd3Vis(slice, payload) {
- let chart;
- let colorKey = 'key';
+ let colorKey = slice.formData.colorKey ? slice.formData.colorKey : 'key';
const isExplore = $('#explore-container').length === 1;
let data;
@@ -112,22 +124,6 @@ function nvd3Vis(slice, payload) {
slice.container.html('');
slice.clearError();
-
- // Calculates the longest label size for stretching bottom margin
- function calculateStretchMargins(payloadData) {
- let stretchMargin = 0;
- const pixelsPerCharX = 4.5; // approx, depends on font size
- let maxLabelSize = 10; // accommodate for shorter labels
- payloadData.data.forEach((d) => {
- const axisLabels = d.values;
- for (let i = 0; i < axisLabels.length; i++) {
- maxLabelSize = Math.max(axisLabels[i].x.toString().length, maxLabelSize);
- }
- });
- stretchMargin = Math.ceil(pixelsPerCharX * maxLabelSize);
- return stretchMargin;
- }
-
let width = slice.width();
const fd = slice.formData;
@@ -150,7 +146,8 @@ function nvd3Vis(slice, payload) {
let stacked = false;
let row;
- const drawGraph = function () {
+ const drawGraph = intervalCallback => function () {
+ let chart;
let svg = d3.select(slice.selector).select('svg');
if (svg.empty()) {
svg = d3.select(slice.selector).append('svg');
@@ -337,6 +334,7 @@ function nvd3Vis(slice, payload) {
chart.height(height);
slice.container.css('height', height + 'px');
+
if (chart.forceY &&
fd.y_axis_bounds &&
(fd.y_axis_bounds[0] !== null || fd.y_axis_bounds[1] !== null)) {
@@ -371,6 +369,7 @@ function nvd3Vis(slice, payload) {
const yAxisFormatter = d3FormatPreset(fd.y_axis_format);
if (chart.yAxis && chart.yAxis.tickFormat) {
chart.yAxis.tickFormat(yAxisFormatter);
+ chart.rightAlignYAxis(!!fd.rightAlignYAxis);
}
if (chart.y2Axis && chart.y2Axis.tickFormat) {
chart.y2Axis.tickFormat(yAxisFormatter);
@@ -399,7 +398,8 @@ function nvd3Vis(slice, payload) {
} else if (vizType !== 'bullet') {
chart.color(d => d.color || getColorFromScheme(d[colorKey], fd.color_scheme));
}
- if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) {
+ if (((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) ||
+ intervalCallback) {
chart.useInteractiveGuideline(true);
if (vizType === 'line') {
// Custom sorted tooltip
@@ -428,10 +428,9 @@ function nvd3Vis(slice, payload) {
}
}
-
if (fd.bottom_margin === 'auto') {
if (vizType === 'dist_bar') {
- const stretchMargin = calculateStretchMargins(payload);
+ const stretchMargin = calculateStretchMargins(data);
chart.margin({ bottom: stretchMargin });
} else {
chart.margin({ bottom: 50 });
@@ -467,9 +466,17 @@ function nvd3Vis(slice, payload) {
const maxYAxisLabelWidth = chart.yAxis2 ? getMaxLabelSize(slice.container, 'nv-y1')
: getMaxLabelSize(slice.container, 'nv-y');
const maxXAxisLabelHeight = getMaxLabelSize(slice.container, 'nv-x');
- chart.margin({ left: maxYAxisLabelWidth + marginPad });
- if (fd.y_axis_label && fd.y_axis_label !== '') {
- chart.margin({ left: maxYAxisLabelWidth + marginPad + 25 });
+
+ if (fd.rightAlignYAxis) {
+ chart.margin({ right: maxYAxisLabelWidth + marginPad });
+ if (fd.y_axis_label && fd.y_axis_label !== '') {
+ chart.margin({ right: maxYAxisLabelWidth + marginPad + 25 });
+ }
+ } else {
+ chart.margin({ left: maxYAxisLabelWidth + marginPad });
+ if (fd.y_axis_label && fd.y_axis_label !== '') {
+ chart.margin({ left: maxYAxisLabelWidth + marginPad + 25 });
+ }
}
// Hack to adjust margins to accommodate long axis tick labels.
// - has to be done only after the chart has been rendered once
@@ -478,16 +485,14 @@ function nvd3Vis(slice, payload) {
// - adjust margins based on these measures and render again
if (isTimeSeries && vizType !== 'bar') {
const chartMargins = {
- bottom: maxXAxisLabelHeight + marginPad,
- right: maxXAxisLabelHeight + marginPad,
+ bottom: Math.max(maxXAxisLabelHeight + marginPad, chart.margin().bottom),
+ right: Math.max(maxXAxisLabelHeight + marginPad, chart.margin().right),
};
if (vizType === 'dual_line') {
const maxYAxis2LabelWidth = getMaxLabelSize(slice.container, 'nv-y2');
// use y axis width if it's wider than axis width/height
- if (maxYAxis2LabelWidth > maxXAxisLabelHeight) {
- chartMargins.right = maxYAxis2LabelWidth + marginPad;
- }
+ chartMargins.right = Math.max(maxYAxis2LabelWidth + marginPad, chartMargins.right);
}
// apply margins
chart.margin(chartMargins);
@@ -499,10 +504,14 @@ function nvd3Vis(slice, payload) {
if (fd.bottom_margin && fd.bottom_margin !== 'auto') {
chart.margin().bottom = fd.bottom_margin;
}
+
if (fd.left_margin && fd.left_margin !== 'auto') {
chart.margin().left = fd.left_margin;
+ } else if (fd.rightAlignYAxis) {
+ chart.margin().left = 0;
}
+
// Axis labels
const margins = chart.margin();
if (fd.x_axis_label && fd.x_axis_label !== '' && chart.xAxis) {
@@ -517,8 +526,10 @@ function nvd3Vis(slice, payload) {
if (fd.y_axis_label && fd.y_axis_label !== '' && chart.yAxis) {
let distance = 0;
- if (margins.left && !isNaN(margins.left)) {
+ if (!fd.rightAlignYAxis && margins.left && !isNaN(margins.left)) {
distance = margins.left - 70;
+ } else if (fd.rightAlignYAxis && margins.right && !isNaN(margins.right)) {
+ distance = margins.right - 100;
}
chart.yAxis.axisLabel(fd.y_axis_label).axisLabelDistance(distance);
}
@@ -725,6 +736,43 @@ function nvd3Vis(slice, payload) {
.attr('width', width)
.call(chart);
}
+ if (intervalCallback) {
+ let start = null;
+ // Adding a layer for selecting an interval
+ d3.select(slice.selector).select('.nv-wrap').append('g').attr('class', 'nv-selection')
+ .append('rect');
+ chart.interactiveLayer.dispatch.elementMouseDown.on('selection', (e) => {
+ if (e.mouseX) {
+ start = e;
+ chart.interactiveLayer.dispatch.elementMousemove.on('selection', (selection) => {
+ d3.selectAll('.nv-selection rect')
+ .attr('x', Math.min(start.mouseX, selection.mouseX))
+ .attr('y', 0)
+ .attr('width', Math.abs(selection.mouseX - start.mouseX))
+ .attr('height', height)
+ .style('fill', d3.rgb('#00A699'))
+ .style('opacity', 0.2);
+ });
+ }
+ });
+
+ chart.interactiveLayer.dispatch.elementMouseUp.on('selection', (e) => {
+ if (e.mouseX) {
+ d3.select('.nv-selection rect')
+ .attr('x', 0)
+ .attr('y', 0)
+ .attr('width', 0)
+ .attr('height', 0);
+ chart.interactiveLayer.dispatch.elementMousemove.on('selection',
+ () => {});
+ // ignore selections of less than 20pixels -> probably accidental
+ if (start && Math.abs(e.mouseX - start.mouseX) > 20) {
+ intervalCallback(start.pointXValue, e.pointXValue);
+ }
+ }
+ });
+ }
+
return chart;
};
@@ -732,8 +780,7 @@ function nvd3Vis(slice, payload) {
// there are left over tooltips in the dom,
// this will clear them before rendering the chart again.
hideTooltips();
-
- nv.addGraph(drawGraph);
+ nv.addGraph(drawGraph(payload.intervalCallback));
}
module.exports = nvd3Vis;
diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js
index 1dce5245f174..fb396febe207 100644
--- a/superset/assets/webpack.config.js
+++ b/superset/assets/webpack.config.js
@@ -21,6 +21,7 @@ const config = {
explore: ['babel-polyfill', APP_DIR + '/javascripts/explore/index.jsx'],
dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/index.jsx'],
sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'],
+ swivel: ['babel-polyfill', APP_DIR + '/javascripts/swivel/index.jsx'],
welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome/index.jsx'],
profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'],
},
diff --git a/superset/config.py b/superset/config.py
index c2da1db8b55c..ed4ef0d64117 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -378,5 +378,6 @@ class CeleryConfig(object):
import superset_config
print('Loaded your LOCAL configuration at [{}]'.format(
superset_config.__file__))
-except ImportError:
+except ImportError as e:
+ print(e)
pass
diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py
index 940cc446ee87..ea9f0b465366 100644
--- a/superset/connectors/base/models.py
+++ b/superset/connectors/base/models.py
@@ -194,7 +194,7 @@ def query(self, query_obj):
"""
raise NotImplementedError()
- def values_for_column(self, column_name, limit=10000):
+ def values_for_column(self, column_name, limit=10000, search_string=None):
"""Given a column, returns an iterable of distinct values
This is used to populate the dropdown showing a list of
diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py
index 57038097b7d1..55db4a393cd1 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -5,6 +5,7 @@
import json
import logging
from multiprocessing.pool import ThreadPool
+import re
from dateutil.parser import parse as dparse
from flask import escape, Markup
@@ -913,7 +914,8 @@ def metrics_and_post_aggs(metrics, metrics_dict):
def values_for_column(self,
column_name,
- limit=10000):
+ limit=10000,
+ search_string=None):
"""Retrieve some values for the given column"""
logging.info(
'Getting values for columns [{}] limited to [{}]'
@@ -934,10 +936,27 @@ def values_for_column(self,
threshold=limit,
)
+ if search_string:
+ # Druid can't make the regex case-insensitive :(
+ pattern = ''.join([
+ '[{0}{1}]'.format(c.upper(), c.lower())
+ if c.isalpha() else re.escape(c)
+ for c in search_string])
+
+ filter_params = {
+ 'type': 'regex',
+ 'dimension': column_name,
+ 'pattern': '.*{}.*'.format(pattern),
+ }
+ qry['filter'] = Filter(**filter_params)
+
client = self.cluster.get_pydruid_client()
client.topn(**qry)
df = client.export_pandas()
- return [row[column_name] for row in df.to_records(index=False)]
+ if (df.values.any()):
+ return [row[column_name] for row in df.to_records(index=False)]
+ else:
+ return []
def get_query_str(self, query_obj, phase=1, client=None):
return self.run_query(client=client, phase=phase, **query_obj)
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 6ccddbe79c4b..647cd54e4851 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -9,7 +9,7 @@
import six
import sqlalchemy as sa
from sqlalchemy import (
- and_, asc, Boolean, Column, DateTime, desc, ForeignKey, Integer, or_,
+ and_, asc, Boolean, cast, Column, DateTime, desc, ForeignKey, Integer, or_,
select, String, Text,
)
from sqlalchemy.orm import backref, relationship
@@ -19,7 +19,9 @@
import sqlparse
from superset import db, import_util, sm, utils
-from superset.connectors.base.models import BaseColumn, BaseDatasource, BaseMetric
+from superset.connectors.base.models import (
+ BaseColumn, BaseDatasource, BaseMetric,
+)
from superset.jinja_context import get_template_processor
from superset.models.annotations import Annotation
from superset.models.core import Database
@@ -366,7 +368,7 @@ def data(self):
d['time_grain_sqla'] = grains
return d
- def values_for_column(self, column_name, limit=10000):
+ def values_for_column(self, column_name, limit=10000, search_string=None):
"""Runs query against sqla to retrieve some
sample values for the given column.
"""
@@ -380,6 +382,14 @@ def values_for_column(self, column_name, limit=10000):
.select_from(self.get_from_clause(tp, db_engine_spec))
.distinct(column_name)
)
+
+ if search_string:
+ # cast to String in case we want to search for numeric values
+ qry = qry.where(
+ cast(target_col.sqla_col, String(length=100)).ilike(
+ '%%{}%%'.format(search_string))).order_by(
+ target_col.sqla_col)
+
if limit:
qry = qry.limit(limit)
diff --git a/superset/templates/superset/swivel.html b/superset/templates/superset/swivel.html
new file mode 100644
index 000000000000..9898ba552fa7
--- /dev/null
+++ b/superset/templates/superset/swivel.html
@@ -0,0 +1,20 @@
+
+{% extends "superset/basic.html" %}
+{% block head_css %}
+{{super()}}
+
+{% endblock %}
+{% block title %}{% endblock %}
+{% block body %}
+
+{% endblock %}
+
+{% block tail_js %}
+ {{ super() }}
+ {% with filename="swivel" %}
+ {% include "superset/partials/_script_tag.html" %}
+ {% endwith %}
+{% endblock %}
diff --git a/superset/utils.py b/superset/utils.py
index 8224843213d2..4570ca22e815 100644
--- a/superset/utils.py
+++ b/superset/utils.py
@@ -676,6 +676,8 @@ def setup_cache(app, cache_config):
"""Setup the flask-cache on a flask app"""
if cache_config and cache_config.get('CACHE_TYPE') != 'null':
return Cache(app, config=cache_config)
+ # By default setup a no-op cache
+ return Cache(app, config={'CACHE_TYPE': 'null'})
def zlib_compress(data):
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index c61472739814..d3e36a67df0a 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -2,3 +2,4 @@
from . import core # noqa
from . import sql_lab # noqa
from . import annotations # noqa
+from . import swivel # noqa
diff --git a/superset/views/core.py b/superset/views/core.py
index ec4cce1fb156..4ba7ab27ad04 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -57,7 +57,6 @@
can_access = utils.can_access
DAR = models.DatasourceAccessRequest
-
ALL_DATASOURCE_ACCESS_ERR = __(
'This endpoint requires the `all_datasource_access` permission')
DATASOURCE_MISSING_ERR = __('The datasource seems to have been deleted')
@@ -937,15 +936,15 @@ def clean_fulfilled_requests(session):
def get_form_data(self):
# get form data from url
- if request.args.get('form_data'):
- form_data = request.args.get('form_data')
- elif request.form.get('form_data'):
- # Supporting POST as well as get
- form_data = request.form.get('form_data')
+ if request.method == 'POST':
+ d = request.json
else:
- form_data = '{}'
+ if request.args.get('form_data'):
+ form_data = request.args.get('form_data')
+ else:
+ form_data = '{}'
- d = json.loads(form_data)
+ d = json.loads(form_data)
if request.args.get('viz_type'):
# Converting old URLs
@@ -978,7 +977,7 @@ def get_viz(
@has_access
@expose('/slice/
/')
def slice(self, slice_id):
- viz_obj = self.get_viz(slice_id)
+ viz_obj = self.get_viz(slice_id=slice_id)
endpoint = '/superset/explore/{}/{}?form_data={}'.format(
viz_obj.datasource.type,
viz_obj.datasource.id,
@@ -1092,7 +1091,8 @@ def annotation_json(self, layer_id):
@log_this
@has_access_api
- @expose('/explore_json///')
+ @expose('/explore_json//', methods=['POST'])
+ @expose('/explore_json///', methods=['GET'])
def explore_json(self, datasource_type, datasource_id):
try:
csv = request.args.get('csv') == 'true'
@@ -1236,15 +1236,19 @@ def explore(self, datasource_type, datasource_id):
@api
@has_access_api
+ @cache.memoize(timeout=300)
@expose('/filter////')
- def filter(self, datasource_type, datasource_id, column):
+ @expose('/filter/////')
+ @expose('/filter/////')
+ def filter(self, datasource_type, datasource_id, column, limit=0, search_string=None):
"""
Endpoint to retrieve values for specified column.
:param datasource_type: Type of datasource e.g. table
:param datasource_id: Datasource id
:param column: Column name to retrieve values for
- :return:
+ :param limit: Return at most these entries (default: 10000)
+ :return: search_string: Only return columns containing the search_string
"""
# TODO: Cache endpoint by user, datasource and column
datasource = ConnectorRegistry.get_datasource(
@@ -1256,9 +1260,10 @@ def filter(self, datasource_type, datasource_id, column):
payload = json.dumps(
datasource.values_for_column(
- column,
- config.get('FILTER_SELECT_ROW_LIMIT', 10000),
- ),
+ column_name=column,
+ limit=limit if limit else config.get(
+ 'FILTER_SELECT_ROW_LIMIT', 10000),
+ search_string=search_string),
default=utils.json_int_dttm_ser)
return json_success(payload)
diff --git a/superset/views/swivel.py b/superset/views/swivel.py
new file mode 100644
index 000000000000..b831cadeed8b
--- /dev/null
+++ b/superset/views/swivel.py
@@ -0,0 +1,116 @@
+import json
+
+from flask import request, Response
+from flask_appbuilder import expose
+from flask_babel import gettext as __
+
+from superset import appbuilder, db
+from superset.connectors.connector_registry import ConnectorRegistry
+import superset.models.core as models
+from superset.utils import has_access
+from superset.views.base import (BaseSupersetView, json_error_response)
+
+
+ALL_DATASOURCE_ACCESS_ERR = __(
+ 'This endpoint requires the `all_datasource_access` permission')
+DATASOURCE_MISSING_ERR = __('The datasource seems to have been deleted')
+ACCESS_REQUEST_MISSING_ERR = __(
+ 'The access requests seem to have been deleted')
+USER_MISSING_ERR = __('The user seems to have been deleted')
+DATASOURCE_ACCESS_ERR = __("You don't have access to this datasource")
+
+log_this = models.Log.log_this
+
+
+def json_success(json_msg, status=200):
+ return Response(json_msg, status=status, mimetype='application/json')
+
+
+TYPE_TIMESTAMP = {'TIMESTAMP', 'DATE', 'DATETIME', 'TIME'}
+TYPE_NUMERIC = {'INT', 'FLOAT', 'NUMERIC', 'REAL', 'DECIMAL',
+ 'BIGINT', 'SMALLINT', 'INTEGER'}
+TYPE_BOOL = {'BOOLEAN', 'BOOL'}
+# TYPE_STRING = {'CHAR','VARCHAR', 'NCHAR',
+# 'NVARCHAR', 'TEXT', 'STRING', 'ENUM'}
+
+
+class SwivelView(BaseSupersetView):
+ route_base = '/swivel'
+
+ @expose('', methods=['GET', 'POST'])
+ def list(self):
+ payload = request.args
+ if request.method == 'POST':
+ payload = request.get_json()
+ return self.render_template(
+ 'superset/swivel.html',
+ bootstrap_data=json.dumps(payload))
+
+ def getDimensionType(self, input_type, is_time,
+ is_num, is_string):
+ if is_time or input_type.upper() in TYPE_TIMESTAMP:
+ return 'TIMESTAMP'
+ elif is_num or input_type.upper() in TYPE_NUMERIC:
+ return 'NUMERIC'
+ elif input_type.upper() in TYPE_BOOL:
+ return 'BOOL'
+ else:
+ return 'NVARCHAR'
+
+ @has_access
+ @expose('/fetch_datasource_metadata')
+ @log_this
+ def fetch_datasource_metadata(self):
+ datasource_id, datasource_type = (
+ request.args.get('uid').split('__'))
+ datasource_class = ConnectorRegistry.sources[datasource_type]
+ datasource = (db.session.query(datasource_class)
+ .filter_by(id=int(datasource_id))
+ .first())
+
+ # Check if datasource exists
+ if not datasource:
+ return json_error_response(DATASOURCE_MISSING_ERR)
+
+ # Check permission for datasource
+ if not self.datasource_access(datasource):
+ return json_error_response(DATASOURCE_ACCESS_ERR)
+
+ columns = [{'name': c.verbose_name
+ if c.verbose_name else c.column_name,
+ 'id': c.column_name,
+ 'type': self.getDimensionType(str(c.type),
+ c.is_dttm if hasattr(
+ c, 'is_dttm') else c.is_time,
+ c.is_num, c.is_string),
+ 'groupable': c.groupby,
+ 'filterable': c.filterable} for c in datasource.columns]
+
+ metrics = [{'name': c.verbose_name
+ if c.verbose_name else c.metric_name,
+ 'id': c.metric_name,
+ 'selected': False,
+ 'format': c.d3format} for c in datasource.metrics]
+ if datasource.type == 'table':
+ time_grains = datasource.time_column_grains.get('time_grains')
+ elif datasource.type == 'druid':
+ time_grains = datasource.time_column_grains.get('time_columns')
+
+ return json_success(json.dumps({'columns': columns,
+ 'metrics': metrics,
+ 'time_grains': time_grains}))
+
+
+appbuilder.add_view(SwivelView, 'latest_swivel',
+ label=__('Latest Session'),
+ href='',
+ icon='fa-clock-o',
+ category='Swivel',
+ category_icon='fa-line-chart',
+ category_label=__('Swivel'))
+
+appbuilder.add_link('new_swivel',
+ label=__('New Session'),
+ href='/swivel?new=true',
+ icon='fa-plus',
+ category='Swivel')
diff --git a/tests/core_tests.py b/tests/core_tests.py
index a7edc4ec16c2..aca68666ca92 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -201,8 +201,28 @@ def test_filter_endpoint(self):
'datasource_id=1&datasource_type=table')
# Changing name
- resp = self.get_resp(url.format(tbl_id, slice_id))
- assert len(resp) > 0
+ resp = json.loads(self.get_resp(url.format(tbl_id, slice_id)))
+ assert len(resp) > 1
+ assert 'Carbon Dioxide' in resp
+
+ # Limit to 3
+ url = (
+ '/superset/filter/table/{}/target/3?viz_type=sankey&groupby=source'
+ '&metric=sum__value&flt_col_0=source&flt_op_0=in&flt_eq_0=&'
+ 'slice_id={}&datasource_name=energy_usage&'
+ 'datasource_id=1&datasource_type=table')
+ resp = json.loads(self.get_resp(url.format(tbl_id, slice_id)))
+ assert len(resp) == 3
+
+ # With search_string = 'carbon'
+ url = (
+ '/superset/filter/table/{}/target/100/carbon?'
+ 'viz_type=sankey&groupby=source&'
+ 'metric=sum__value&flt_col_0=source&flt_op_0=in&flt_eq_0=&'
+ 'slice_id={}&datasource_name=energy_usage&'
+ 'datasource_id=1&datasource_type=table')
+ resp = json.loads(self.get_resp(url.format(tbl_id, slice_id)))
+ assert len(resp) == 1
assert 'Carbon Dioxide' in resp
def test_slices(self):
diff --git a/tests/druid_tests.py b/tests/druid_tests.py
index c280da790a29..3f80f732507f 100644
--- a/tests/druid_tests.py
+++ b/tests/druid_tests.py
@@ -328,6 +328,38 @@ def test_sync_druid_perm(self, PyDruid):
permission=permission, view_menu=view_menu).first()
assert pv is not None
+ @patch('superset.connectors.druid.models.PyDruid')
+ def test_values_for_column(self, py_druid):
+ ds = 'test_datasource'
+ column = 'test_column'
+ search_string = '$t1' # difficult test string
+
+ datasource = self.get_or_create(
+ DruidDatasource, {'datasource_name': ds},
+ db.session)
+ druid = py_druid()
+ datasource.cluster.get_pydruid_client = Mock(return_value=druid)
+
+ # search_string
+ datasource.values_for_column(column_name=column, limit=5,
+ search_string=search_string)
+
+ assert druid.topn.call_args[1]['datasource'] == ds
+ assert druid.topn.call_args[1]['granularity'] == 'all'
+ assert druid.topn.call_args[1]['metric'] == 'count'
+ assert druid.topn.call_args[1]['dimension'] == column
+ assert druid.topn.call_args[1]['threshold'] == 5
+
+ # test filter
+ assert(druid.topn.call_args[1]['filter']
+ .filter['filter']['dimension'] == column)
+ assert(druid.topn.call_args[1]['filter']
+ .filter['filter']['pattern'] == '.*\\$[Tt]1.*')
+
+ # no search_string
+ datasource.values_for_column(column_name=column)
+ assert not druid.topn.call_args[1].get('filter')
+
if __name__ == '__main__':
unittest.main()