Compare commits

...

64 Commits

Author SHA1 Message Date
Grace Guo
d326ac7d6c 0.31.0rc18 2019-03-18 16:17:22 -07:00
John Bodley
c43d0fd378 [sqlparse] Fixing table name extraction for ill-defined query (#7029)
(cherry picked from commit 07c340cf82)
2019-03-18 15:41:57 -07:00
Maxime Beauchemin
b64a452a6d [sql lab] improve table name detection in free form SQL (#6793)
* [sql lab] improve table name detection in free form SQL

* flake

* Addressing comments

(cherry picked from commit 5a40f71710)
2019-03-18 15:41:52 -07:00
michellethomas
2357c4aabf Adding custom control overrides (#6956)
* Adding extraOverrides to line chart

* Updating extraOverrides to fit with more cases

* Moving extraOverrides to index.js

* Removing webpack-merge in package.json

* Fixing metrics control clearing metric

(cherry picked from commit e6194051f4)
2019-03-18 15:32:00 -07:00
John Bodley
9dd7e84a31 [sql-parse] Fixing LIMIT exceptions (#6963)
(cherry picked from commit 3e076cb60b)
2019-03-18 15:29:06 -07:00
John Bodley
5d8dd1424f [csv-upload] Fixing message encoding (#6971)
(cherry picked from commit 48431ab5b9)
2019-03-18 15:29:00 -07:00
John Bodley
f454dedd28 [main] Disable resetting main DB attributes (#6845)
(cherry picked from commit 60d5f89faa)
2019-03-18 15:25:32 -07:00
John Bodley
e967b268f4 [sqla] Fixing order-by for non-inner-joins (#6862)
(cherry picked from commit 5728946270)
2019-03-18 15:24:08 -07:00
michellethomas
a5d9a4e005 Adding template_params to datasource editor for sqla tables (#6869)
(cherry picked from commit b0f7f51ab7)
2019-03-18 15:23:58 -07:00
John Bodley
6b8954133a [datasource] Ensuring consistent behavior of datasource editing/saving. (#7037)
* Update datasource.py

* Update datasource.py

(cherry picked from commit c771625f10)
2019-03-18 13:28:43 -07:00
michellethomas
8ef2789f47 Adding warning message for sqllab save query (#7028)
(cherry picked from commit ead3d48133)
2019-03-18 11:26:46 -07:00
Conglei
0ebdb5643b fix inaccurate data calculation with adata rolling and contribution (#7035)
(cherry picked from commit 0782e831cd)
2019-03-17 23:33:34 -07:00
Grace Guo
b3af6a261f [fix] explore chart from dashboard missed slice title (#7046)
(cherry picked from commit a6d48d4052)
2019-03-17 23:33:12 -07:00
John Bodley
c54b067c6a [db-engine-spec] Aligning Hive/Presto partition logic (#7007)
(cherry picked from commit 05be866117)
2019-03-17 23:24:55 -07:00
michellethomas
bd65942e48 Changing time table viz to pass formatTime a date (#7020)
(cherry picked from commit 7f3c145b1f)
2019-03-17 23:20:24 -07:00
Grace Guo
50accda9d8 [fix] Cursor jumping when editing chart and dashboard titles (#7038)
(cherry picked from commit fc1770f7b7)
2019-03-17 23:20:08 -07:00
Christine Chambers
5ace576948 0.31.0rc17 2019-03-14 11:27:22 -07:00
Tom Hunter
927a584678 [WIP] fix user specified JSON metadata not updating dashboard on refresh (#7027)
(cherry picked from commit cc58f0e661)
2019-03-14 11:24:42 -07:00
Grace Guo
fafb824d9a 0.31.0rc16 2019-03-12 13:03:06 -07:00
Grace Guo
7b72985efb [fix] /superset/slice/id url is too long (#6989)
(cherry picked from commit 6a4d507ab6)
2019-03-12 10:54:33 -07:00
Hugh A. Miles II
b497d9e7d1 fix dashboard links in welcome page (#6756)
(cherry picked from commit 6b0ab2100d)
2019-03-12 10:43:20 -07:00
Christine Chambers
c42afa11b9 0.31.0rc15 2019-03-08 16:32:53 -08:00
Conglei
35c55278dd Enhancement of query context and object. (#6962)
* added more functionalities for query context and object.

* fixed cache logic

* added default value for groupby

* updated comments and removed print

(cherry picked from commit d5b9795f87)
2019-03-08 16:29:51 -08:00
Christine Chambers
1c41020c73 Split tags migration (#7002)
This PR removes the iteration over charts, dashboards and saved queries to create tags in the original migration, leaving only the logic to create the tags and the tagged objects tables.
Tested locally by running `superset db downgrade` to revert to the previous migration and then running `superset db upgrade` to the current version.

(cherry picked from commit e47a1b2868)
2019-03-08 16:28:20 -08:00
Christine Chambers
ec7a0b22ab 0.31.0rc14 2019-03-01 19:45:21 -08:00
Maxime Beauchemin
4655cb4c23 Remove Cypress from package.json (#6912)
* Remove Cypress from package.json

I'm building some Docker images these days and realizing just how big
the Cypress package is. Looks like its ~500mb or so.

I prefer adding it as needed only as opposed to having to play tricks as
in `npm ci && rm node_modules/cypress`

* Pin cypress version

* Add script entry install-cypress

* bump cypress and fix ts-jest warning

(cherry picked from commit 8f2ce75665)
2019-03-01 19:44:23 -08:00
Christine Chambers
fb8e3208db 0.31.0rc13 2019-02-28 17:59:24 -08:00
Kim Truong
b4cbe13d22 VIZ-190 fix (#6958)
(cherry picked from commit 5026401171)
2019-02-28 17:57:37 -08:00
Christine Chambers
5b7b22fd25 0.31.0rc12 2019-02-27 10:40:39 -08:00
Beto Dealmeida
5180422942 Fix deck.gl form data (#6953)
* Fix deck.gl viz

* Fix more form data

* Fix a few more places

* Fix unit tests

(cherry picked from commit e0feec9117)
2019-02-27 10:39:22 -08:00
Christine Chambers
9939a52d0d 0.31.0rc11 2019-02-25 16:31:36 -08:00
Christine Chambers
c3db74d902 Fix rendering regression from the introduction of bignumber (#6937)
In superset-ui 0.8.0, we used bignumber.js to transform numbers in chartProps' payload from plain 64-bit floats to BigNumber instances. This causes a number of charts to render incorrectly when comparison functions in the rendering algorithms operate on BigNumber objects instead of floats. This PR uses the preTransformProps step in SuperChart to transform BigNumber instances back to floats so charts can render properly.

(cherry picked from commit 73cdb37f7e)
2019-02-25 16:29:34 -08:00
Christine Chambers
9940d30a7f 0.31.0rc10 2019-02-20 16:12:08 -08:00
Christine Chambers
3df2b8d57b Add a safety check before getting clientHeight (#6923)
Seeing an intermittent repro of the `current` nodes of the sql editor and south pane refs returning null. Adding a safety check for both nodes.

(cherry picked from commit c04c0cd8f0)
2019-02-20 14:31:59 -08:00
Christine Chambers
ccb51385e4 v0.31.0rc9 2019-02-19 16:24:51 -08:00
Beto Dealmeida
db0235fbdb Fix database typeahead in SQL Lab (#6917)
* Fix database typeahead in SQL Lab

* Fix lint

* Use string interpolation

(cherry picked from commit 25ec00b3c6)
2019-02-19 16:11:09 -08:00
Christine Chambers
953d6dc9d6 Address tooltip's disappearance and stickiness (#6898)
* Address tooltip's disappearance and stickiness

Nvd3 attaches tooltips to the body of the dom, not the chart the tooltip is meant fo. On hover, it sets their opacity to 1. In order to address both their stickiness when chart reloads (PR #6805) and thier disappearance on scroll in dashboards (PR #6852), we introduce a shouldRemove parameter to `hideTooltips` and only remove them befor chart reloads. For the scroll events triggered on dashboards, we only hide the tooltips by setting their opacity to 0. When they get hovered over again, nvd3 sets their opacity to 1 which causes them to reappear.

* adding a comment about the shouldRemove parameter
2019-02-19 15:45:16 -08:00
Beto Dealmeida
c0eaa5f62d Fix extra_filters in multi line viz (#6868)
(cherry picked from commit b035185b1c5c4f4332bfc8c9f748166f8d43151f)
2019-02-19 12:51:08 -08:00
Beto Dealmeida
ebcadc1f50 Fix tooltip (#6895)
(cherry picked from commit 3f96b0c5c4)
2019-02-19 12:12:51 -08:00
Christine Chambers
5fa5acb5d5 Add show metadata button back to the explore view (#6911)
* Add show metadata button back to the explore view

- Add the show metadta button, accidentally removed from PR #6046, back to the explore view
- Remove dead code that is no longer reachable from DataSourceModal.jsx.

* Adding additional code back to make the button function and remove more dead code.

(cherry picked from commit f8cf0fb7f3)
2019-02-19 09:47:04 -08:00
Christine Chambers
ce76560ae8 v0.31.0rc8 2019-02-14 20:30:35 -08:00
Christine Chambers
8c549b46bd Relayout SQL Editor (#6872)
* Relayout SQL Editor

- Refactor SQL editor to remove usage of bootstrap col, row and collapse to simplify the layout
- Replace the react-split-pane libraray with react-split to allow custom styling of the gutter area without sacrifice correctness of the ace editor height calculation
- Rewrite the left pane animation via plain css transition and animate it to slide in and out
- General code and css clean up

* Smooth out the visual transition during dragging

(cherry picked from commit 19f82b729c7a939f12b1c5da6022c0fd76fa3ec9)

* Adjust how the height of the south pane is computed, fixing cypress tests

(cherry picked from commit ec6657ab2d)
2019-02-14 20:28:45 -08:00
Maxime Beauchemin
bfe18963d7 [cosmetic] TableSelector use <i> instead of <Button> for refresh (#6783)
* [cosmetic] TableSelector use <i> instead of <Button> for refresh

* Add ASF licenses

* css hover

* missing license

* remove license header

(cherry picked from commit 713b0ae4f4)
2019-02-14 20:28:20 -08:00
Christine Chambers
19b588b52b 0.31.0rc7 2019-02-12 10:21:18 -08:00
michellethomas
d7e038eaa5 Fixing issue where tooltip gets hidden on dashboard for all charts (#6852)
(cherry picked from commit 4638618545)
2019-02-11 16:52:32 -08:00
Christine Chambers
38e0ddacf1 0.31.0rc6 2019-02-08 15:17:17 -08:00
Krist Wongsuphasawat
b7d2bd09a7 Fix line chart overflowing the right side (#6829)
* Fix line chart overflowing the right side

* revert package-lock.json

* revert again

(cherry picked from commit 823555e07d)
2019-02-08 15:17:17 -08:00
Maxime Beauchemin
b7e02ab776 [sql lab] fix stuck offline (#6782)
(cherry picked from commit 36176f3e20)
2019-02-08 09:27:43 -08:00
Christine Chambers
8a7c245c54 0.31.0rc5 2019-02-06 14:29:26 -08:00
Beto Dealmeida
f24efa7250 Backend only tagging system (#6823)
This PR introduces the backend changes for a tagging system for Superset, allowing dashboards, charts and queries to be tagged. It also allows searching for a given tag, and will be the basis for a new landing page (see #5327).

# Implicit tags
Dashboard, chart and (saved) queries have implicit tags related to their owners, types and favorites. For example, all objects owned by the admin have the tag `owner:1`. All charts have the tag `type:chart`. Objects favorited by the admin have the tag `favorited_by:1`.

These tags are automatically added by a migration script, and kept in sync through SQLAlchemy event listeners. They are currently not surfaced to the user, but can be searched for. For example, it's possible to search for `owner:1` in the welcome page to see all objects owned by the admin, or even search for `owner:{{ current_user_id() }}`.

(cherry picked from commit 8041b63af6)
2019-02-06 14:18:50 -08:00
John Bodley
1ddacc42d0 [wtforms] Using wtforms-json which supports None (#5445)
(cherry picked from commit e1b907783a)
2019-02-06 14:17:51 -08:00
Christine Chambers
4f37b9aefc 0.31.0rc4 2019-02-04 18:58:54 -08:00
Michael McDuffee
845c7aa91c creating new circular-json safe stringify and replacing one call (#6772)
(cherry picked from commit 11a7ad00b7)
2019-02-04 18:54:39 -08:00
michellethomas
8ea805ea0a Fixing sort issue with area chart and adding tests (#6358)
(cherry picked from commit 8100a8fa97)
2019-02-04 18:50:55 -08:00
Beto Dealmeida
aff43c7453 Allow specifying custom width for logo (#6739)
(cherry picked from commit cf1a35b94b)
2019-02-04 18:49:30 -08:00
Beto Dealmeida
7f86517970 Remove test URL (#6740)
(cherry picked from commit bbd781b66e)
2019-02-04 18:48:24 -08:00
Christine Chambers
ed0f0ab2d8 0.31.0rc30.31.0rc30.31.0rc3 2019-02-04 16:13:23 -08:00
Grace Guo
db81dc50b1 [fix] Add action for update chart id (#6769)
(cherry picked from commit 744135c7fe)
2019-02-04 15:30:52 -08:00
Grace Guo
37de92b883 [fix] JS error out when rename a new chart (#6752)
(cherry picked from commit 879c553b0a)
2019-02-04 15:30:27 -08:00
Christine Chambers
4d01a02f21 0.31.0rc2 2019-02-01 18:41:37 -08:00
Christine Chambers
0e48e05008 Fix sticky tooltips on nvd3 vizzes
Currently, we attempt to hide the nvd3 tooltips (if any were on screen) before we draw a new viz after rerunning a query. The hiding is done by selecting the first nvtooltip element and setting the opacity to 0.

This somtimes leave behind a trail of old tooltips if a tooltip is left behind by this nvd3 bug https://github.com/novus/nvd3/issues/1262. This PR modifies the behavior of how we clean up tooltips between rerun of queries by selecting all nvd3 tooltips and removing them all from the DOM before redrawing nvd3 vizzes.

(cherry picked from commit 501340b5db)
2019-02-01 15:38:22 -08:00
Beto Dealmeida
ae95c8930b Fix playslider
(cherry picked from commit a09348d0ec)
2019-02-01 15:37:49 -08:00
Krist Wongsuphasawat
58e3a39f55 Add iframe and markup legacy plugin (#6741)
* Add iframe plugin

* Use lazy load and add description

* remove unintended files

* Add markup

* minor adjustment

(cherry picked from commit 3ae7d32caa)
2019-02-01 15:32:55 -08:00
Christine Chambers
b80b0b90e2 0.31.0rc1 2019-01-24 13:59:41 -08:00
97 changed files with 2599 additions and 1469 deletions

View File

@@ -27,6 +27,11 @@ assists people when migrating to a new version.
run `pip install superset[presto]` and/or `pip install superset[hive]` as
required.
* [5445](https://github.com/apache/incubator-superset/pull/5445) : a change
which prevents encoding of empty string from form data in the datanbase.
This involves a non-schema changing migration which does potentially impact
a large number of records. Scheduled downtime may be advised.
## Superset 0.31.0
* boto3 / botocore was removed from the dependency list. If you use s3
as a place to store your SQL Lab result set or Hive uploads, you may

View File

@@ -43,7 +43,7 @@ mako==1.0.7 # via alembic
markdown==3.0
markupsafe==1.0 # via jinja2, mako
numpy==1.15.2 # via pandas
pandas==0.23.1
pandas==0.23.4
parsedatetime==2.0.0
pathlib2==2.3.0
polyline==1.3.2
@@ -60,7 +60,7 @@ requests==2.20.0
retry==0.9.2
selenium==3.141.0
simplejson==3.15.0
six==1.11.0 # via bleach, cryptography, isodate, pathlib2, polyline, pydruid, python-dateutil, sqlalchemy-utils
six==1.11.0 # via bleach, cryptography, isodate, pathlib2, polyline, pydruid, python-dateutil, sqlalchemy-utils, wtforms-json
sqlalchemy-utils==0.32.21
sqlalchemy==1.2.2
sqlparse==0.2.4
@@ -69,4 +69,5 @@ urllib3==1.22 # via requests, selenium
vine==1.1.4 # via amqp
webencodings==0.5.1 # via bleach
werkzeug==0.14.1 # via flask
wtforms==2.2.1 # via flask-wtf
wtforms-json==0.3.3
wtforms==2.2.1 # via flask-wtf, wtforms-json

View File

@@ -104,6 +104,7 @@ setup(
'sqlalchemy-utils',
'sqlparse',
'unicodecsv',
'wtforms-json',
],
extras_require={
'cors': ['flask-cors>=2.0.0'],

View File

@@ -28,6 +28,7 @@ from flask_compress import Compress
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect
from werkzeug.contrib.fixers import ProxyFix
import wtforms_json
from superset import config
from superset.connectors.connector_registry import ConnectorRegistry
@@ -35,6 +36,8 @@ from superset.security import SupersetSecurityManager
from superset.utils.core import (
get_update_perms_flag, pessimistic_connection_handling, setup_cache)
wtforms_json.init()
APP_DIR = os.path.dirname(__file__)
CONFIG_MODULE = os.environ.get('SUPERSET_CONFIG', 'superset.config')

View File

@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import readResponseBlob from '../../../utils/readResponseBlob';
export default () => describe('Area', () => {
const AREA_FORM_DATA = {
datasource: '2__table',
@@ -71,11 +73,12 @@ export default () => describe('Area', () => {
...AREA_FORM_DATA,
groupby: ['region'],
});
cy.get('.nv-area').should('have.length', 7);
});
it('should work with groupby and filter', () => {
verify({
cy.visitChartByParams(JSON.stringify({
...AREA_FORM_DATA,
groupby: ['region'],
adhoc_filters: [{
@@ -88,6 +91,18 @@ export default () => describe('Area', () => {
fromFormData: true,
filterOptionName: 'filter_txje2ikiv6_wxmn0qwd1xo',
}],
}));
cy.wait('@getJson').then(async (xhr) => {
cy.verifyResponseCodes(xhr);
const responseBody = await readResponseBlob(xhr.response.body);
// Make sure data is sorted correctly
const firstRow = responseBody.data[0].values;
const secondRow = responseBody.data[1].values;
expect(firstRow[firstRow.length - 1].y).to.be.greaterThan(secondRow[secondRow.length - 1].y);
cy.verifySliceContainer('svg');
});
cy.get('.nv-area').should('have.length', 2);
});

View File

@@ -98,10 +98,40 @@ export default () => describe('Line', () => {
metrics,
time_compare: ['1+year'],
comparison_type: 'values',
groupby: ['gender'],
};
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' });
// Offset color should match original line color
cy.get('.nv-legend-text')
.contains('boy')
.siblings()
.first()
.should('have.attr', 'style')
.then((style) => {
cy.get('.nv-legend-text')
.contains('boy, 1 year offset')
.siblings()
.first()
.should('have.attr', 'style')
.and('eq', style);
});
cy.get('.nv-legend-text')
.contains('girl')
.siblings()
.first()
.should('have.attr', 'style')
.then((style) => {
cy.get('.nv-legend-text')
.contains('girl, 1 year offset')
.siblings()
.first()
.should('have.attr', 'style')
.and('eq', style);
});
});
it('Test line chart with time shift yoy', () => {

View File

@@ -29,6 +29,7 @@ flask run -p 8081 --with-threads --reload --debugger &
#block on the longer running javascript process
time npm ci
time npm run install-cypress
time npm run build
echo "[completed js build steps]"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "superset",
"version": "0.999.0dev",
"version": "0.31.0rc18",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"license": "Apache-2.0",
"directories": {
@@ -19,7 +19,8 @@
"lint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx . && tslint -c tslint.json --fix ./{src,spec}/**/*.ts{,x}",
"sync-backend": "babel-node --preset=@babel/preset-env src/syncBackend.js",
"cypress": "cypress",
"cypress-debug": "cypress open --config watchForFileChanges=true"
"cypress-debug": "cypress open --config watchForFileChanges=true",
"install-cypress": "npm install cypress@3.1.5"
},
"repository": {
"type": "git",
@@ -62,6 +63,7 @@
"@vx/responsive": "0.0.172",
"@vx/scale": "^0.0.165",
"abortcontroller-polyfill": "^1.1.9",
"bignumber.js": "^8.1.1",
"bootstrap": "^3.3.6",
"bootstrap-slider": "^10.0.0",
"brace": "^0.11.1",
@@ -115,9 +117,10 @@
"react-select": "1.2.1",
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.8.3",
"react-split-pane": "^0.1.66",
"react-split": "^2.0.4",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-transition-group": "^2.5.3",
"react-virtualized": "9.19.1",
"react-virtualized-select": "^3.1.3",
"reactable-arc": "0.14.42",
@@ -156,7 +159,6 @@
"cache-loader": "^1.2.2",
"clean-webpack-plugin": "^0.1.19",
"css-loader": "^1.0.0",
"cypress": "^3.0.3",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"eslint": "^4.19.1",
@@ -192,7 +194,7 @@
"terser-webpack-plugin": "^1.1.0",
"thread-loader": "^1.2.0",
"transform-loader": "^0.2.3",
"ts-jest": "^23.10.4",
"ts-jest": "^24.0.0",
"ts-loader": "^5.2.0",
"tslint": "^5.11.0",
"tslint-react": "^3.6.0",

View File

@@ -0,0 +1,53 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import BigNumber from 'bignumber.js';
import transform from 'src/chart/transformBigNumber';
describe('transformBigNumber', () => {
it('should transform BigNumber on its own', () => {
expect(transform(new BigNumber(123.456))).toBe(123.456);
});
it('should transform BigNumber in objects', () => {
expect(transform({
foo: new BigNumber(123),
bar: 456,
baz: null,
})).toEqual({ foo: 123, bar: 456, baz: null });
});
it('should transform BigNumber in arrays', () => {
expect(transform([
{ foo: new BigNumber(123) },
{ bar: 456 },
])).toEqual([{ foo: 123 }, { bar: 456 }]);
});
it('should transform BigNumber in nested structures', () => {
expect(transform([{
x: new BigNumber(123),
y: [{ foo: new BigNumber(456) }, { bar: 'str' }],
z: { some: [new BigNumber(789)] },
}])).toEqual([{
x: 123,
y: [{ foo: 456 }, { bar: 'str' }],
z: { some: [789] },
}]);
});
});

View File

@@ -24,7 +24,7 @@ import { shallow } from 'enzyme';
import { STATUS_OPTIONS } from '../../../src/SqlLab/constants';
import { initialState } from './fixtures';
import SouthPane from '../../../src/SqlLab/components/SouthPane';
import SouthPaneContainer, { SouthPane } from '../../../src/SqlLab/components/SouthPane';
describe('SouthPane', () => {
const middlewares = [thunk];
@@ -42,11 +42,16 @@ describe('SouthPane', () => {
};
const getWrapper = () => (
shallow(<SouthPane {...mockedProps} />, {
shallow(<SouthPaneContainer {...mockedProps} />, {
context: { store },
}).dive());
let wrapper;
beforeAll(() => {
jest.spyOn(SouthPane.prototype, 'getSouthPaneHeight').mockImplementation(() => 500);
});
it('should render offline when the state is offline', () => {
wrapper = getWrapper();
wrapper.setProps({ offline: true });

View File

@@ -38,6 +38,11 @@ describe('SqlEditor', () => {
defaultQueryLimit: 1000,
maxRow: 100000,
};
beforeAll(() => {
jest.spyOn(SqlEditor.prototype, 'getSqlEditorHeight').mockImplementation(() => 500);
});
it('is valid', () => {
expect(
React.isValidElement(<SqlEditor {...mockedProps} />),

View File

@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { safeStringify } from '../../../src/utils/safeStringify';
class Noise {
public next: Noise;
}
describe('Stringify utility testing', () => {
it('correctly parses a simple object just like JSON', () => {
const noncircular = {
b: 'foo',
c: 'bar',
d: [
{
e: 'hello',
f: ['world'],
},
{
e: 'hello',
f: ['darkness', 'my', 'old', 'friend'],
},
],
};
expect(safeStringify(noncircular)).toEqual(JSON.stringify(noncircular));
// Checking that it works with quick-deepish-copies as well.
expect(JSON.parse(safeStringify(noncircular))).toEqual(JSON.parse(JSON.stringify(noncircular)));
});
it('handles simple circular json as expected', () => {
const ping = new Noise();
const pong = new Noise();
const pang = new Noise();
ping.next = pong;
pong.next = ping;
// ping.next is pong (the circular reference) now
const safeString = safeStringify(ping);
ping.next = pang;
// ping.next is pang now, which has no circular reference, so it's safe to use JSON.stringify
const ordinaryString = JSON.stringify(ping);
expect(safeString).toEqual(ordinaryString);
});
it('creates a parseable object even when the input is circular', () => {
const ping = new Noise();
const pong = new Noise();
ping.next = pong;
pong.next = ping;
const newNoise: Noise = JSON.parse(safeStringify(ping));
expect(newNoise).toBeTruthy();
expect(newNoise.next).toEqual({});
});
it('does not remove noncircular duplicates', () => {
const a = {
foo: 'bar',
};
const repeating = {
first: a,
second: a,
third: a,
};
expect(safeStringify(repeating)).toEqual(JSON.stringify(repeating));
});
it('does not remove nodes with empty objects', () => {
const emptyObjectValues = {
a: {},
b: 'foo',
c: {
d: 'good data here',
e: {},
},
};
expect(safeStringify(emptyObjectValues)).toEqual(JSON.stringify(emptyObjectValues));
});
it('does not remove nested same keys', () => {
const nestedKeys = {
a: 'b',
c: {
a: 'd',
x: 'y',
},
};
expect(safeStringify(nestedKeys)).toEqual(JSON.stringify(nestedKeys));
});
});

View File

@@ -30,7 +30,7 @@ describe('getBreakPoints', () => {
});
it('returns sorted break points', () => {
const fd = { break_points: ['0', '10', '100', '50', '1000'] };
const fd = { breakPoints: ['0', '10', '100', '50', '1000'] };
const result = getBreakPoints(fd, [], metricAccessor);
const expected = ['0', '10', '50', '100', '1000'];
expect(result).toEqual(expected);
@@ -45,7 +45,7 @@ describe('getBreakPoints', () => {
});
it('formats number with proper precision', () => {
const fd = { metric: 'count', num_buckets: 2 };
const fd = { metric: 'count', numBuckets: 2 };
const features = [0, 1 / 3, 2 / 3, 1].map(count => ({ count }));
const result = getBreakPoints(fd, features, metricAccessor);
const expected = ['0.0', '0.5', '1.0'];
@@ -53,7 +53,7 @@ describe('getBreakPoints', () => {
});
it('works with a zero range', () => {
const fd = { metric: 'count', num_buckets: 1 };
const fd = { metric: 'count', numBuckets: 1 };
const features = [1, 1, 1].map(count => ({ count }));
const result = getBreakPoints(fd, features, metricAccessor);
const expected = ['1', '1'];
@@ -69,7 +69,7 @@ describe('getBreakPointColorScaler', () => {
it('returns linear color scaler if there are no break points', () => {
const fd = {
metric: 'count',
linear_color_scheme: ['#000000', '#ffffff'],
linearColorScheme: ['#000000', '#ffffff'],
opacity: 100,
};
const features = [10, 20, 30].map(count => ({ count }));
@@ -82,8 +82,8 @@ describe('getBreakPointColorScaler', () => {
it('returns bucketing scaler if there are break points', () => {
const fd = {
metric: 'count',
linear_color_scheme: ['#000000', '#ffffff'],
break_points: ['0', '1', '10'],
linearColorScheme: ['#000000', '#ffffff'],
breakPoints: ['0', '1', '10'],
opacity: 100,
};
const features = [];
@@ -97,8 +97,8 @@ describe('getBreakPointColorScaler', () => {
it('mask values outside the break points', () => {
const fd = {
metric: 'count',
linear_color_scheme: ['#000000', '#ffffff'],
break_points: ['0', '1', '10'],
linearColorScheme: ['#000000', '#ffffff'],
breakPoints: ['0', '1', '10'],
opacity: 100,
};
const features = [];
@@ -116,8 +116,8 @@ describe('getBuckets', () => {
it('computes buckets for break points', () => {
const fd = {
metric: 'count',
linear_color_scheme: ['#000000', '#ffffff'],
break_points: ['0', '1', '10'],
linearColorScheme: ['#000000', '#ffffff'],
breakPoints: ['0', '1', '10'],
opacity: 100,
};
const features = [];

View File

@@ -84,7 +84,7 @@ class App extends React.PureComponent {
content = (
<div>
<QueryAutoRefresh />
<TabbedSqlEditors getHeight={this.getHeight} />
<TabbedSqlEditors />
</div>
);
}

View File

@@ -72,6 +72,8 @@ class QueryAutoRefresh extends React.PureComponent {
}).catch(() => {
this.props.actions.setUserOffline(true);
});
} else {
this.props.actions.setUserOffline(false);
}
}
render() {

View File

@@ -31,11 +31,13 @@ const propTypes = {
dbId: PropTypes.number,
animation: PropTypes.bool,
onSave: PropTypes.func,
saveQueryWarning: PropTypes.string,
};
const defaultProps = {
defaultLabel: t('Undefined'),
animation: true,
onSave: () => {},
saveQueryWarning: null,
};
class SaveQuery extends React.PureComponent {
@@ -108,6 +110,18 @@ class SaveQuery extends React.PureComponent {
</Col>
</Row>
<br />
{this.props.saveQueryWarning && (
<div>
<Row>
<Col md={12}>
<small>
{this.props.saveQueryWarning}
</small>
</Col>
</Row>
<br />
</div>
)}
<Row>
<Col md={12}>
<Button

View File

@@ -48,7 +48,24 @@ const defaultProps = {
offline: false,
};
class SouthPane extends React.PureComponent {
export class SouthPane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
height: props.height,
};
this.southPaneRef = React.createRef();
this.getSouthPaneHeight = this.getSouthPaneHeight.bind(this);
this.switchTab = this.switchTab.bind(this);
}
componentWillReceiveProps() {
// south pane expands the entire height of the tab content on mount
this.setState({ height: this.getSouthPaneHeight() });
}
// One layer of abstraction for easy spying in unit tests
getSouthPaneHeight() {
return this.southPaneRef.current ? this.southPaneRef.current.clientHeight : 0;
}
switchTab(id) {
this.props.actions.setActiveSouthPaneTab(id);
}
@@ -59,7 +76,7 @@ class SouthPane extends React.PureComponent {
{ STATUS_OPTIONS.offline }
</Label>);
}
const innerTabHeight = this.props.height - 55;
const innerTabHeight = this.state.height - 55;
let latestQuery;
const props = this.props;
if (props.editorQueries.length > 0) {
@@ -98,12 +115,12 @@ class SouthPane extends React.PureComponent {
));
return (
<div className="SouthPane">
<div className="SouthPane" ref={this.southPaneRef}>
<Tabs
bsStyle="tabs"
id={shortid.generate()}
activeKey={this.props.activeSouthPaneTab}
onSelect={this.switchTab.bind(this)}
onSelect={this.switchTab}
>
<Tab
title={t('Results')}

View File

@@ -17,21 +17,18 @@
* under the License.
*/
import React from 'react';
import { CSSTransition } from 'react-transition-group';
import PropTypes from 'prop-types';
import { throttle } from 'lodash';
import {
Col,
FormGroup,
InputGroup,
Form,
FormControl,
Label,
OverlayTrigger,
Row,
Tooltip,
Collapse,
} from 'react-bootstrap';
import SplitPane from 'react-split-pane';
import Split from 'react-split';
import { t } from '@superset-ui/translation';
import Button from '../../components/Button';
@@ -47,9 +44,13 @@ import AceEditorWrapper from './AceEditorWrapper';
import { STATE_BSSTYLE_MAP } from '../constants';
import RunQueryActionButton from './RunQueryActionButton';
const SQL_TOOLBAR_HEIGHT = 51;
const GUTTER_HEIGHT = 5;
const INITIAL_NORTH_PERCENT = 30;
const INITIAL_SOUTH_PERCENT = 70;
const propTypes = {
actions: PropTypes.object.isRequired,
getHeight: PropTypes.func.isRequired,
database: PropTypes.object,
latestQuery: PropTypes.object,
tables: PropTypes.array.isRequired,
@@ -59,12 +60,14 @@ const propTypes = {
hideLeftBar: PropTypes.bool,
defaultQueryLimit: PropTypes.number.isRequired,
maxRow: PropTypes.number.isRequired,
saveQueryWarning: PropTypes.string,
};
const defaultProps = {
database: null,
latestQuery: null,
hideLeftBar: false,
saveQueryWarning: null,
};
class SqlEditor extends React.PureComponent {
@@ -75,13 +78,18 @@ class SqlEditor extends React.PureComponent {
ctas: '',
sql: props.queryEditor.sql,
};
this.sqlEditorRef = React.createRef();
this.northPaneRef = React.createRef();
this.onResize = this.onResize.bind(this);
this.throttledResize = throttle(this.onResize, 250);
this.onResizeStart = this.onResizeStart.bind(this);
this.onResizeEnd = this.onResizeEnd.bind(this);
this.runQuery = this.runQuery.bind(this);
this.stopQuery = this.stopQuery.bind(this);
this.onSqlChanged = this.onSqlChanged.bind(this);
this.setQueryEditorSql = this.setQueryEditorSql.bind(this);
this.queryPane = this.queryPane.bind(this);
this.getAceEditorAndSouthPaneHeights = this.getAceEditorAndSouthPaneHeights.bind(this);
this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this);
}
componentWillMount() {
if (this.state.autorun) {
@@ -91,29 +99,41 @@ class SqlEditor extends React.PureComponent {
}
}
componentDidMount() {
this.onResize();
window.addEventListener('resize', this.throttledResize);
// We need to measure the height of the sql editor post render to figure the height of
// the south pane so it gets rendered properly
// eslint-disable-next-line react/no-did-mount-set-state
this.setState({ height: this.getSqlEditorHeight() });
}
componentWillUnmount() {
window.removeEventListener('resize', this.throttledResize);
onResizeStart() {
// Set the heights on the ace editor and the ace content area after drag starts
// to smooth out the visual transition to the new heights when drag ends
document.getElementById('brace-editor').style.height = `calc(100% - ${SQL_TOOLBAR_HEIGHT}px)`;
document.getElementsByClassName('ace_content')[0].style.height = '100%';
}
onResize() {
const height = this.sqlEditorHeight();
const editorPaneHeight = this.props.queryEditor.height || 200;
const splitPaneHandlerHeight = 8; // 4px of height + 4px of top-margin
this.setState({
editorPaneHeight,
southPaneHeight: height - editorPaneHeight - splitPaneHandlerHeight,
height,
});
onResizeEnd([northPercent, southPercent]) {
this.setState(this.getAceEditorAndSouthPaneHeights(
this.state.height, northPercent, southPercent));
if (this.refs.ace && this.refs.ace.clientHeight) {
this.props.actions.persistEditorHeight(this.props.queryEditor, this.refs.ace.clientHeight);
if (this.northPaneRef.current && this.northPaneRef.current.clientHeight) {
this.props.actions.persistEditorHeight(this.props.queryEditor,
this.northPaneRef.current.clientHeight);
}
}
onSqlChanged(sql) {
this.setState({ sql });
}
// One layer of abstraction for easy spying in unit tests
getSqlEditorHeight() {
return this.sqlEditorRef.current ? this.sqlEditorRef.current.clientHeight : 0;
}
// Return the heights for the ace editor and the south pane as an object
// given the height of the sql editor, north pane percent and south pane percent.
getAceEditorAndSouthPaneHeights(height, northPercent, southPercent) {
return {
aceEditorHeight: height * northPercent / 100 - SQL_TOOLBAR_HEIGHT - GUTTER_HEIGHT / 2,
southPaneHeight: height * southPercent / 100,
};
}
getHotkeyConfig() {
return [
{
@@ -187,9 +207,42 @@ class SqlEditor extends React.PureComponent {
ctasChanged(event) {
this.setState({ ctas: event.target.value });
}
sqlEditorHeight() {
const horizontalScrollbarHeight = 25;
return parseInt(this.props.getHeight(), 10) - horizontalScrollbarHeight;
queryPane() {
const hotkeys = this.getHotkeyConfig();
const { aceEditorHeight, southPaneHeight } = this.getAceEditorAndSouthPaneHeights(
this.state.height, INITIAL_NORTH_PERCENT, INITIAL_SOUTH_PERCENT);
return (
<div className="queryPane">
<Split
sizes={[INITIAL_NORTH_PERCENT, INITIAL_SOUTH_PERCENT]}
minSize={200}
direction="vertical"
gutterSize={GUTTER_HEIGHT}
onDragStart={this.onResizeStart}
onDragEnd={this.onResizeEnd}
>
<div ref={this.northPaneRef}>
<AceEditorWrapper
actions={this.props.actions}
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
sql={this.props.queryEditor.sql}
tables={this.props.tables}
height={`${this.state.aceEditorHeight || aceEditorHeight}px`}
hotkeys={hotkeys}
/>
{this.renderEditorBottomBar(hotkeys)}
</div>
<SouthPane
editorQueries={this.props.editorQueries}
dataPreviewQueries={this.props.dataPreviewQueries}
actions={this.props.actions}
height={this.state.southPaneHeight || southPaneHeight}
/>
</Split>
</div>
);
}
renderEditorBottomBar(hotkeys) {
let ctasControls;
@@ -258,6 +311,7 @@ class SqlEditor extends React.PureComponent {
onSave={this.props.actions.saveQuery}
schema={qe.schema}
dbId={qe.dbId}
saveQueryWarning={this.props.saveQueryWarning}
/>
</span>
<span className="m-r-5">
@@ -305,74 +359,23 @@ class SqlEditor extends React.PureComponent {
);
}
render() {
const height = this.sqlEditorHeight();
const defaultNorthHeight = this.props.queryEditor.height || 200;
const hotkeys = this.getHotkeyConfig();
return (
<div
className="SqlEditor"
style={{
height: height + 'px',
}}
>
<Row>
<Collapse
in={!this.props.hideLeftBar}
>
<Col
xs={6}
sm={5}
md={4}
lg={3}
>
<SqlEditorLeftBar
height={height}
database={this.props.database}
queryEditor={this.props.queryEditor}
tables={this.props.tables}
actions={this.props.actions}
/>
</Col>
</Collapse>
<Col
xs={this.props.hideLeftBar ? 12 : 6}
sm={this.props.hideLeftBar ? 12 : 7}
md={this.props.hideLeftBar ? 12 : 8}
lg={this.props.hideLeftBar ? 12 : 9}
style={{ height: this.state.height }}
>
<SplitPane
split="horizontal"
defaultSize={defaultNorthHeight}
minSize={100}
onChange={this.onResize}
>
<div ref="ace" style={{ width: '100%' }}>
<div>
<AceEditorWrapper
actions={this.props.actions}
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
sql={this.props.queryEditor.sql}
tables={this.props.tables}
height={((this.state.editorPaneHeight || defaultNorthHeight) - 50) + 'px'}
hotkeys={hotkeys}
/>
{this.renderEditorBottomBar(hotkeys)}
</div>
</div>
<div ref="south">
<SouthPane
editorQueries={this.props.editorQueries}
dataPreviewQueries={this.props.dataPreviewQueries}
actions={this.props.actions}
height={this.state.southPaneHeight || 0}
/>
</div>
</SplitPane>
</Col>
</Row>
<div ref={this.sqlEditorRef} className="SqlEditor">
<CSSTransition
classNames="schemaPane"
in={!this.props.hideLeftBar}
timeout={300}
>
<div className="schemaPane">
<SqlEditorLeftBar
database={this.props.database}
queryEditor={this.props.queryEditor}
tables={this.props.tables}
actions={this.props.actions}
/>
</div>
</CSSTransition>
{this.queryPane()}
</div>
);
}

View File

@@ -20,7 +20,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import TableElement from './TableElement';
import TableSelector from '../../components/TableSelector';
@@ -106,7 +105,7 @@ export default class SqlEditorLeftBar extends React.PureComponent {
const tableMetaDataHeight = this.props.height - 130; // 130 is the height of the selects above
const qe = this.props.queryEditor;
return (
<div className="clearfix">
<div className="sqlEditorLeftBar">
<TableSelector
dbId={qe.dbId}
schema={qe.schema}

View File

@@ -39,12 +39,13 @@ const propTypes = {
queryEditors: PropTypes.array,
tabHistory: PropTypes.array.isRequired,
tables: PropTypes.array.isRequired,
getHeight: PropTypes.func.isRequired,
offline: PropTypes.bool,
saveQueryWarning: PropTypes.string,
};
const defaultProps = {
queryEditors: [],
offline: false,
saveQueryWarning: null,
};
let queryCount = 1;
@@ -238,7 +239,6 @@ class TabbedSqlEditors extends React.PureComponent {
<div className="panel-body">
{isSelected && (
<SqlEditor
getHeight={this.props.getHeight}
tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditor={qe}
editorQueries={this.state.queriesArray}
@@ -249,6 +249,7 @@ class TabbedSqlEditors extends React.PureComponent {
hideLeftBar={this.state.hideLeftBar}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
saveQueryWarning={this.props.saveQueryWarning}
/>
)}
</div>
@@ -291,6 +292,7 @@ function mapStateToProps({ sqlLab, common }) {
offline: sqlLab.offline,
defaultQueryLimit: common.conf.DEFAULT_SQLLAB_LIMIT,
maxRow: common.conf.SQL_MAX_ROW,
saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE,
};
}
function mapDispatchToProps(dispatch) {

View File

@@ -135,7 +135,8 @@ div.Workspace {
background-color: #e8e8e8;
display: flex;
justify-content: space-between;
border-bottom: 2px solid #ccc;
border: 1px solid #ccc;
border-top: 0;
form {
margin-block-end: 0;
@@ -193,21 +194,67 @@ div.Workspace {
background-color: transparent !important;
}
.SqlEditor {
.Resizer {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
.SqlLab {
.tab-content {
height: 100%;
}
.Resizer.horizontal {
height: 4px;
#brace-editor {
height: calc(100% - 51px);
}
.ace_content {
height: 100%;
}
.SouthPane {
height: 100%;
}
}
.SqlEditor {
display: flex;
flex-direction: row;
height: 100%;
.schemaPane {
flex-grow: 1;
transition: all .3s ease-in-out;
}
.schemaPane-enter-done, .schemaPane-exit {
transform: translateX(0);
}
.schemaPane-enter-active, .schemaPane-exit-active {
transform: translateX(-50%);
}
.schemaPane-enter, .schemaPane-exit-done {
transform: translateX(-100%);
max-width: 0;
overflow: hidden;
}
.queryPane {
flex-grow: 8;
position: relative;
margin-left: 15px;
}
.schemaPane-exit-done + .queryPane {
margin-left: 0;
}
.gutter {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
width: 3%;
margin: 3px 47%;
}
.gutter.gutter-vertical {
cursor: row-resize;
width: 4%;
margin-top: 4px;
margin-left: 47%;
}
}
@@ -298,9 +345,6 @@ a.Link {
.tooltip-inner {
max-width: 500px;
}
.SplitPane.horizontal {
padding-right: 4px;
}
.SouthPane {
margin-top: 10px;
position: absolute;

View File

@@ -24,6 +24,7 @@ import { ChartProps } from '@superset-ui/chart';
import { Tooltip } from 'react-bootstrap';
import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
import SuperChart from '../visualizations/core/components/SuperChart';
import transformBigNumber from './transformBigNumber';
const propTypes = {
annotationData: PropTypes.object,
@@ -67,9 +68,10 @@ class ChartRenderer extends React.Component {
this.handleAddFilter = this.handleAddFilter.bind(this);
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
this.handleRenderFailure = this.handleRenderFailure.bind(this);
this.preTransformProps = this.preTransformProps.bind(this);
}
shouldComponentUpdate(nextProps) {
shouldComponentUpdate(nextProps, nextState) {
if (
nextProps.queryResponse &&
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
@@ -79,6 +81,7 @@ class ChartRenderer extends React.Component {
nextProps.queryResponse !== this.props.queryResponse ||
nextProps.height !== this.props.height ||
nextProps.width !== this.props.width ||
nextState.tooltip !== this.state.tooltip ||
nextProps.triggerRender)
) {
return true;
@@ -149,6 +152,18 @@ class ChartRenderer extends React.Component {
});
}
preTransformProps(chartProps) {
const payload = chartProps.payload;
const data = transformBigNumber(payload.data);
return new ChartProps({
...chartProps,
payload: {
...payload,
data,
},
});
}
renderTooltip() {
const { tooltip } = this.state;
if (tooltip && tooltip.content) {
@@ -192,6 +207,7 @@ class ChartRenderer extends React.Component {
className={`${snakeCase(vizType)}`}
chartType={vizType}
chartProps={skipChartRendering ? null : this.prepareChartProps()}
preTransformProps={this.preTransformProps}
onRenderSuccess={this.handleRenderSuccess}
onRenderFailure={this.handleRenderFailure}
/>

View File

@@ -152,6 +152,13 @@ export function updateQueryFormData(value, key) {
return { type: UPDATE_QUERY_FORM_DATA, value, key };
}
// in the sql lab -> explore flow, user can inline edit chart title,
// then the chart will be assigned a new slice_id
export const UPDATE_CHART_ID = 'UPDATE_CHART_ID';
export function updateChartId(newId, key = 0) {
return { type: UPDATE_CHART_ID, newId, key };
}
export const ADD_CHART = 'ADD_CHART';
export function addChart(chart, key) {
return { type: ADD_CHART, chart, key };
@@ -239,7 +246,10 @@ export function runQuery(formData, force = false, timeout = 60, key) {
export function redirectSQLLab(formData) {
return (dispatch) => {
const { url } = getExploreUrlAndPayload({ formData, endpointType: 'query' });
return SupersetClient.get({ url })
return SupersetClient.post({
url,
postPayload: { form_data: formData },
})
.then(({ json }) => {
const redirectUrl = new URL(window.location);
redirectUrl.pathname = '/superset/sqllab';

View File

@@ -168,6 +168,14 @@ export default function chartReducer(charts = {}, action) {
if (action.type === actions.REMOVE_CHART) {
delete charts[action.key];
return charts;
} else if (action.type === actions.UPDATE_CHART_ID) {
const { newId, key } = action;
charts[newId] = {
...charts[key],
id: newId,
};
delete charts[key];
return charts;
}
if (action.type in actionHandlers) {

View File

@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// This method transforms any BigNumber object in the given payload to its
// 64-bit float representation. It is a temporary fix so charts receive
// floats instead of BigNumber instances in their props to properly render.
import BigNumber from 'bignumber.js';
export default function transform(payload) {
if (!payload) {
return payload;
} else if (BigNumber.isBigNumber(payload)) {
return payload.toNumber();
} else if (payload.constructor === Object) {
for (const key in payload) {
if (payload.hasOwnProperty(key)) {
// Modify in place to prevent creating large payloads
// eslint-disable-next-line no-param-reassign
payload[key] = transform(payload[key]);
}
}
} else if (payload.constructor === Array) {
payload.forEach((elem, idx) => {
// Modify in place to prevent creating large payloads
// eslint-disable-next-line no-param-reassign
payload[idx] = transform(elem);
});
}
return payload;
}

View File

@@ -83,17 +83,15 @@ class AsyncSelect extends React.PureComponent {
render() {
return (
<div>
<Select
placeholder={this.props.placeholder}
options={this.state.options}
value={this.props.value}
isLoading={this.state.isLoading}
onChange={this.onChange}
valueRenderer={this.props.valueRenderer}
{...this.props}
/>
</div>
<Select
placeholder={this.props.placeholder}
options={this.state.options}
value={this.props.value}
isLoading={this.state.isLoading}
onChange={this.onChange}
valueRenderer={this.props.valueRenderer}
{...this.props}
/>
);
}
}

View File

@@ -55,7 +55,6 @@ export default class EditableTitle extends React.PureComponent {
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
// Used so we can access the DOM element if a user clicks on this component.
@@ -112,21 +111,16 @@ export default class EditableTitle extends React.PureComponent {
}
}
handleKeyUp(ev) {
// this entire method exists to support using EditableTitle as the title of a
// react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
//
// tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
// clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
// the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
// keydown is still called so we can detect this and manually add a ' ' to the current title
if (ev.key === ' ') {
let title = ev.target.value;
const titleLength = (title || '').length;
if (title && title[titleLength - 1] !== ' ') {
title = `${title} `;
this.setState(() => ({ title }));
}
// this entire method exists to support using EditableTitle as the title of a
// react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
//
// tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
// clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
// the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
// keydown is still called so we can detect this and manually add a ' ' to the current title
handleKeyDown(event) {
if (event.key === ' ') {
event.stopPropagation();
}
}
@@ -170,7 +164,7 @@ export default class EditableTitle extends React.PureComponent {
required
value={value}
className={!title ? 'text-muted' : null}
onKeyUp={this.handleKeyUp}
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}
@@ -184,7 +178,7 @@ export default class EditableTitle extends React.PureComponent {
type={isEditing ? 'text' : 'button'}
value={value}
className={!title ? 'text-muted' : null}
onKeyUp={this.handleKeyUp}
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}

View File

@@ -18,49 +18,26 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import { Label } from 'react-bootstrap';
import TooltipWrapper from './TooltipWrapper';
import './RefreshLabel.less';
const propTypes = {
onClick: PropTypes.func,
className: PropTypes.string,
tooltipContent: PropTypes.string.isRequired,
};
class RefreshLabel extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hovered: false,
};
}
mouseOver() {
this.setState({ hovered: true });
}
mouseOut() {
this.setState({ hovered: false });
}
render() {
const labelStyle = this.state.hovered ? 'primary' : 'default';
const tooltip = 'Click to ' + this.props.tooltipContent;
return (
<TooltipWrapper
tooltip={tooltip}
tooltip={this.props.tooltipContent}
label="cache-desc"
>
<Label
className={this.props.className}
bsStyle={labelStyle}
style={{ fontSize: '13px', marginRight: '5px', cursor: 'pointer' }}
<i
className="RefreshLabel fa fa-refresh pointer"
onClick={this.props.onClick}
onMouseOver={this.mouseOver.bind(this)}
onMouseOut={this.mouseOut.bind(this)}
>
<i className="fa fa-refresh" />
</Label>
/>
</TooltipWrapper>);
}
}

View File

@@ -0,0 +1,27 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
@import "../../stylesheets/less/cosmo/variables.less";
.RefreshLabel:hover {
color: @brand-primary;
}
.RefreshLabel {
color: @gray-light;
}

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
.TableSelector .fa-refresh {
padding-left: 9px;
}
.TableSelector .refresh-col {
display: flex;
align-items: center;
width: 30px;
}
.TableSelector .section {
padding-bottom: 5px;
display: flex;
flex-direction: row;
}
.TableSelector .select {
flex-grow: 1;
}
.TableSelector .divider {
border-bottom: 1px solid #f2f2f2;
margin: 10px 0;
}

View File

@@ -20,12 +20,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-virtualized-select';
import createFilterOptions from 'react-select-fast-filter-options';
import { ControlLabel, Col, Label } from 'react-bootstrap';
import { ControlLabel, Label } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import AsyncSelect from './AsyncSelect';
import RefreshLabel from './RefreshLabel';
import './TableSelector.css';
const propTypes = {
dbId: PropTypes.number.isRequired,
@@ -37,7 +38,6 @@ const propTypes = {
tableNameSticky: PropTypes.bool,
tableName: PropTypes.string,
database: PropTypes.object,
horizontal: PropTypes.bool,
sqlLabMode: PropTypes.bool,
onChange: PropTypes.func,
clearable: PropTypes.bool,
@@ -51,7 +51,6 @@ const defaultProps = {
onTableChange: () => {},
onChange: () => {},
tableNameSticky: true,
horizontal: false,
sqlLabMode: true,
clearable: true,
};
@@ -112,7 +111,11 @@ export default class TableSelector extends React.PureComponent {
if (data.result.length === 0) {
this.props.handleError(t("It seems you don't have access to any database"));
}
return data.result;
return data.result.map(row => ({
...row,
// label is used for the typeahead
label: `${row.backend} ${row.database_name}`,
}));
}
fetchTables(force, substr) {
// This can be large so it shouldn't be put in the Redux store
@@ -196,8 +199,16 @@ export default class TableSelector extends React.PureComponent {
{db.database_name}
</span>);
}
renderDatabaseSelect() {
renderSelectRow(select, refreshBtn) {
return (
<div className="section">
<span className="select">{select}</span>
<span className="refresh-col">{refreshBtn}</span>
</div>
);
}
renderDatabaseSelect() {
return this.renderSelectRow(
<AsyncSelect
dataEndpoint={
'/databaseasync/api/' +
@@ -223,33 +234,25 @@ export default class TableSelector extends React.PureComponent {
/>);
}
renderSchema() {
return (
<div className="m-t-5">
<div className="row">
<div className="col-md-11 col-xs-11 p-r-2">
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema}
/>
return this.renderSelectRow(
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
<div className="col-md-1 col-xs-1 p-l-0 p-t-8">
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('force refresh schema list')}
/>
</div>
</div>
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.changeSchema}
/>,
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('Force refresh schema list')}
/>,
);
}
renderTable() {
@@ -262,49 +265,39 @@ export default class TableSelector extends React.PureComponent {
tableSelectDisabled = true;
}
const options = this.addOptionIfMissing(this.state.tableOptions, this.state.tableName);
return (
<div className="m-t-5">
<div className="row">
<div className="col-md-11 col-xs-11 p-r-2">
{this.props.schema ? (
<Select
name="select-table"
ref="selectTable"
isLoading={this.state.tableLoading}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable}
filterOptions={this.state.filterOptions}
options={options}
value={this.state.tableName}
/>
) : (
<Select
async
name="async-select-table"
ref="selectTable"
placeholder={tableSelectPlaceholder}
disabled={tableSelectDisabled}
autosize={false}
onChange={this.changeTable}
value={this.state.tableName}
loadOptions={this.getTableNamesBySubStr}
/>
)}
</div>
<div className="col-md-1 col-xs-1 p-l-0 p-t-8">
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('force refresh table list')}
/>
</div>
</div>
</div>);
const select = this.props.schema ? (
<Select
name="select-table"
ref="selectTable"
isLoading={this.state.tableLoading}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable}
filterOptions={this.state.filterOptions}
options={options}
value={this.state.tableName}
/>) : (
<Select
async
name="async-select-table"
ref="selectTable"
placeholder={tableSelectPlaceholder}
disabled={tableSelectDisabled}
autosize={false}
onChange={this.changeTable}
value={this.state.tableName}
loadOptions={this.getTableNamesBySubStr}
/>);
return this.renderSelectRow(
select,
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('Force refresh table list')}
/>);
}
renderSeeTableLabel() {
return (
<div>
<hr />
<div className="section">
<ControlLabel>
{t('See table schema')}{' '}
<small>
@@ -318,21 +311,15 @@ export default class TableSelector extends React.PureComponent {
</div>);
}
render() {
if (this.props.horizontal) {
return (
<div>
<Col md={4}>{this.renderDatabaseSelect()}</Col>
<Col md={4}>{this.renderSchema()}</Col>
<Col md={4}>{this.renderTable()}</Col>
</div>);
}
return (
<div>
<div>{this.renderDatabaseSelect()}</div>
<div className="m-t-5">{this.renderSchema()}</div>
<div className="TableSelector">
{this.renderDatabaseSelect()}
{this.renderSchema()}
<div className="divider" />
{this.props.sqlLabMode && this.renderSeeTableLabel()}
<div className="m-t-5">{this.renderTable()}</div>
</div>);
{this.renderTable()}
</div>
);
}
}
TableSelector.propTypes = propTypes;

View File

@@ -253,7 +253,10 @@ export function addSliceToDashboard(id) {
),
);
}
const form_data = selectedSlice.form_data;
const form_data = {
...selectedSlice.form_data,
slice_id: selectedSlice.slice_id,
};
const newChart = {
...initChart,
id,

View File

@@ -448,6 +448,14 @@ export class DatasourceEditor extends React.PureComponent {
label={t('Hours offset')}
control={<TextControl />}
/>
{ this.state.isSqla &&
<Field
fieldKey="template_params"
label={t('Template parameters')}
descr={t('A set of parameters that become available in the query using Jinja templating syntax')}
control={<TextControl />}
/>
}
</Fieldset>);
}

View File

@@ -49,10 +49,8 @@ class DatasourceModal extends React.PureComponent {
super(props);
this.state = {
errors: [],
showDatasource: false,
datasource: props.datasource,
};
this.toggleShowDatasource = this.toggleShowDatasource.bind(this);
this.setSearchRef = this.setSearchRef.bind(this);
this.onDatasourceChange = this.onDatasourceChange.bind(this);
this.onClickSave = this.onClickSave.bind(this);
@@ -111,10 +109,6 @@ class DatasourceModal extends React.PureComponent {
this.dialog = ref;
}
toggleShowDatasource() {
this.setState({ showDatasource: !this.state.showDatasource });
}
renderSaveDialog() {
return (
<div>

View File

@@ -65,6 +65,7 @@ class ExploreChartHeader extends React.PureComponent {
.then((json) => {
const { data } = json;
if (isNewSlice) {
this.props.actions.updateChartId(data.slice.slice_id, 0);
this.props.actions.createNewSlice(
data.can_add, data.can_download, data.can_overwrite,
data.slice, data.form_data);

View File

@@ -109,6 +109,9 @@ class ExploreViewContainer extends React.Component {
const wasRendered =
['rendered', 'failed', 'stopped'].indexOf(this.props.chart.chartStatus) > -1;
const isRendered = ['rendered', 'failed', 'stopped'].indexOf(nextProps.chart.chartStatus) > -1;
if (nextProps.chart.id !== this.props.chart.id) {
this.loadingLog.sourceId = nextProps.chart.id;
}
if (!wasRendered && isRendered) {
Logger.send(this.loadingLog);
}

View File

@@ -176,10 +176,12 @@ AnnotationLayerControl.defaultProps = defaultProps;
// directly, could not figure out how to get access to the color_scheme
function mapStateToProps({ charts, explore }) {
const chartKey = getChartKey(explore);
const chart = charts[chartKey] || charts[0] || {};
return {
colorScheme: (explore.controls || {}).color_scheme.value,
annotationError: charts[chartKey].annotationError,
annotationQuery: charts[chartKey].annotationQuery,
annotationError: chart.annotationError,
annotationQuery: chart.annotationQuery,
vizType: explore.controls.viz_type.value,
};
}

View File

@@ -19,13 +19,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Col,
Collapse,
Label,
OverlayTrigger,
Row,
Tooltip,
Well,
} from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import ControlHeader from '../ControlHeader';
import ColumnOption from '../../../components/ColumnOption';
import MetricOption from '../../../components/MetricOption';
import DatasourceModal from '../../../datasource/DatasourceModal';
const propTypes = {
@@ -52,25 +58,51 @@ class DatasourceControl extends React.PureComponent {
};
this.toggleShowDatasource = this.toggleShowDatasource.bind(this);
this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this);
}
onChange(vizType) {
this.props.onChange(vizType);
this.setState({ showModal: false });
this.renderDatasource = this.renderDatasource.bind(this);
}
toggleShowDatasource() {
this.setState(({ showDatasource }) => ({ showDatasource: !showDatasource }));
}
toggleModal() {
this.setState(({ showModal }) => ({ showModal: !showModal }));
}
toggleEditDatasourceModal() {
this.setState(({ showEditDatasourceModal }) => ({
showEditDatasourceModal: !showEditDatasourceModal,
}));
}
renderDatasource() {
const datasource = this.props.datasource;
return (
<div className="m-t-10">
<Well className="m-t-0">
<div className="m-b-10">
<Label>
<i className="fa fa-database" /> {datasource.database.backend}
</Label>
{` ${datasource.database.name} `}
</div>
<Row className="datasource-container">
<Col md={6}>
<strong>Columns</strong>
{datasource.columns.map(col => (
<div key={col.column_name}>
<ColumnOption showType column={col} />
</div>
))}
</Col>
<Col md={6}>
<strong>Metrics</strong>
{datasource.metrics.map(m => (
<div key={m.metric_name}>
<MetricOption metric={m} showType />
</div>
))}
</Col>
</Row>
</Well>
</div>
);
}
render() {
return (
<div>
@@ -85,6 +117,21 @@ class DatasourceControl extends React.PureComponent {
{this.props.datasource.name}
</Label>
</OverlayTrigger>
<OverlayTrigger
placement="right"
overlay={
<Tooltip id={'toggle-datasource-tooltip'}>
{t('Expand/collapse datasource configuration')}
</Tooltip>
}
>
<a href="#">
<i
className={`fa fa-${this.state.showDatasource ? 'minus' : 'plus'}-square m-r-5`}
onClick={this.toggleShowDatasource}
/>
</a>
</OverlayTrigger>
{this.props.datasource.type === 'table' &&
<OverlayTrigger
placement="right"
@@ -102,6 +149,7 @@ class DatasourceControl extends React.PureComponent {
<i className="fa fa-flask m-r-5" />
</a>
</OverlayTrigger>}
<Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
<DatasourceModal
datasource={this.props.datasource}
show={this.state.showEditDatasourceModal}

View File

@@ -60,6 +60,21 @@ function isDictionaryForAdhocMetric(value) {
return value && !(value instanceof AdhocMetric) && value.expressionType;
}
function columnsContainAllMetrics(value, nextProps) {
const columnNames = new Set(
[...nextProps.columns, ...nextProps.savedMetrics]
// eslint-disable-next-line camelcase
.map(({ column_name, metric_name }) => (column_name || metric_name)),
);
return (Array.isArray(value) ? value : [value])
.filter(metric => metric)
// find column names
.map(metric => metric.column ? metric.column.column_name : metric.column_name || metric)
.filter(name => name)
.every(name => columnNames.has(name));
}
// adhoc metrics are stored as dictionaries in URL params. We convert them back into the
// AdhocMetric class for typechecking, consistency and instance method access.
function coerceAdhocMetrics(value) {
@@ -135,14 +150,22 @@ export default class MetricsControl extends React.PureComponent {
}
componentWillReceiveProps(nextProps) {
const { value } = this.props;
if (
isEqual(this.props.columns) !== isEqual(nextProps.columns) ||
isEqual(this.props.savedMetrics) !== isEqual(nextProps.savedMetrics)
!isEqual(this.props.columns, nextProps.columns) ||
!isEqual(this.props.savedMetrics, nextProps.savedMetrics)
) {
this.setState({ options: this.optionsForSelect(nextProps) });
this.props.onChange([]);
// Remove metrics if selected value no longer a column
const containsAllMetrics = columnsContainAllMetrics(value, nextProps);
if (!containsAllMetrics) {
this.props.onChange([]);
}
}
if (this.props.value !== nextProps.value) {
if (value !== nextProps.value) {
this.setState({ value: coerceAdhocMetrics(nextProps.value) });
}
}

View File

@@ -0,0 +1,22 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// For individual deployments to add custom overrides
export default function extraOverrides(controlPanelConfigs) {
return controlPanelConfigs;
}

View File

@@ -22,6 +22,7 @@
*/
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import * as sections from './sections';
import extraOverrides from './extraOverrides';
import Area from './Area';
import Bar from './Bar';
@@ -72,7 +73,7 @@ import DeckPolygon from './DeckPolygon';
import DeckScatter from './DeckScatter';
import DeckScreengrid from './DeckScreengrid';
export const controlPanelConfigs = {
export const controlPanelConfigs = extraOverrides({
area: Area,
bar: Bar,
big_number: BigNumber,
@@ -122,7 +123,7 @@ export const controlPanelConfigs = {
deck_scatter: DeckScatter,
deck_screengrid: DeckScreengrid,
};
});
export default controlPanelConfigs;

View File

@@ -19,6 +19,7 @@
/* eslint camelcase: 0 */
import URI from 'urijs';
import { availableDomains } from '../utils/hostNamesConfig';
import { safeStringify } from '../utils/safeStringify';
const MAX_URL_LENGTH = 8000;
@@ -71,7 +72,7 @@ export function getExploreLongUrl(formData, endpointType, allowOverflow = true,
Object.keys(extraSearch).forEach((key) => {
search[key] = extraSearch[key];
});
search.form_data = JSON.stringify(formData);
search.form_data = safeStringify(formData);
if (endpointType === 'standalone') {
search.standalone = 'true';
}

View File

@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* A Stringify function that will not crash when it runs into circular JSON references,
* unlike JSON.stringify. Any circular references are simply omitted, as if there had
* been no data present
* @param object any JSON object to be stringified
*/
export function safeStringify(object: any): string {
const cache = new Set();
return JSON.stringify(object, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (cache.has(value)) {
// We've seen this object before
try {
// Quick deep copy to duplicate if this is a repeat rather than a circle.
return JSON.parse(JSON.stringify(value));
} catch (err) {
// Discard key if value cannot be duplicated.
return;
}
}
// Store the value in our cache.
cache.add(value);
}
return value;
});
}

View File

@@ -0,0 +1,58 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Mustache from 'mustache';
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
className: PropTypes.string,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
url: PropTypes.string,
};
const defaultProps = {
className: '',
};
class Iframe extends React.PureComponent {
render() {
const { className, url, width, height } = this.props;
const completeUrl = Mustache.render(url, {
width,
height,
});
return (
<iframe
className={className}
title="superset-iframe"
src={completeUrl}
style={{
width: '100%',
height,
}}
/>
);
}
}
Iframe.propTypes = propTypes;
Iframe.defaultProps = defaultProps;
export default Iframe;

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import thumbnail from './images/thumbnail.png';
import transformProps from './transformProps';
const metadata = new ChartMetadata({
name: t('IFrame'),
description: 'HTML Inline Frame',
thumbnail,
});
export default class IframeChartPlugin extends ChartPlugin {
constructor() {
super({
metadata,
loadChart: () => import('./Iframe.jsx'),
transformProps,
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -16,23 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import Mustache from 'mustache';
export default function iframeWidget(slice) {
const { selector, formData } = slice;
export default function transformProps(chartProps) {
const { width, height, formData } = chartProps;
const { url } = formData;
const width = slice.width();
const height = slice.height();
const container = document.querySelector(selector);
const completedUrl = Mustache.render(url, {
return {
width,
height,
});
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.height = height;
iframe.setAttribute('src', completedUrl);
container.appendChild(iframe);
url,
};
}

View File

@@ -17,16 +17,16 @@
* under the License.
*/
.markup.slice_container {
margin: 10px;
margin: 10px;
}
.separator {
background-color: transparent !important;
background-color: transparent !important;
}
.separator hr {
border: 0;
height: 1px;
background-image: linear-gradient(to right, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
border: 0;
height: 1px;
background-image: linear-gradient(to right, rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1), rgba(0, 0, 0, 0));
}
.separator .chart-header {
border: none !important;
border: none !important;
}

View File

@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import './Markup.css';
const propTypes = {
className: PropTypes.string,
height: PropTypes.number.isRequired,
isSeparator: PropTypes.bool,
html: PropTypes.string,
cssFiles: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
className: '',
isSeparator: false,
html: '',
};
const CONTAINER_STYLE = {
position: 'relative',
overflow: 'auto',
};
class Markup extends React.PureComponent {
render() {
const { className, height, isSeparator, html, cssFiles } = this.props;
return (
<div
className={className}
style={CONTAINER_STYLE}
>
<iframe
title="superset-markup"
frameBorder={0}
height={isSeparator ? height - 20 : height}
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation"
srcDoc={`
<html>
<head>
${cssFiles.map(
href => `<link rel="stylesheet" type="text/css" href="${href}" />`,
)}
</head>
<body style="background-color: transparent;">
${html}
</body>
</html>`
}
/>
</div>
);
}
}
Markup.propTypes = propTypes;
Markup.defaultProps = defaultProps;
export default Markup;

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import thumbnail from './images/thumbnail.png';
import transformProps from './transformProps';
const metadata = new ChartMetadata({
name: t('Markup'),
description: 'HTML Markup',
thumbnail,
});
export default class IframeChartPlugin extends ChartPlugin {
constructor() {
super({
metadata,
loadChart: () => import('./Markup.jsx'),
transformProps,
});
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,33 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export default function transformProps(chartProps) {
const { height, payload, formData } = chartProps;
const { vizType } = formData;
const {
theme_css: cssFiles,
html,
} = payload.data;
return {
height,
cssFiles,
html,
isSeparator: vizType === 'separator',
};
}

View File

@@ -23,6 +23,7 @@ import { scaleLinear } from 'd3-scale';
import { Table, Thead, Th, Tr, Td } from 'reactable-arc';
import { formatNumber } from '@superset-ui/number-format';
import { formatTime } from '@superset-ui/time-format';
import moment from 'moment';
import MetricOption from '../../components/MetricOption';
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
@@ -148,7 +149,7 @@ class TimeTable extends React.PureComponent {
renderTooltip={({ index }) => (
<div>
<strong>{formatNumber(column.d3format, sparkData[index])}</strong>
<div>{formatTime(column.dateFormat, entries[index].time)}</div>
<div>{formatTime(column.dateFormat, moment.utc(entries[index].time).toDate())}</div>
</div>
)}
/>

View File

@@ -31,9 +31,9 @@ import { fitViewport } from './layers/common';
const { getScale } = CategoricalColorNamespace;
function getCategories(fd, data) {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const c = fd.colorPicker || { r: 0, g: 0, b: 0, a: 1 };
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
const colorFn = getScale(fd.color_scheme);
const colorFn = getScale(fd.colorScheme);
const categories = {};
data.forEach((d) => {
if (d.cat_color != null && !categories.hasOwnProperty(d.cat_color)) {
@@ -70,7 +70,7 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
*/
constructor(props) {
super(props);
this.state = this.getInitialStateFromProps(props);
this.state = this.getStateFromProps(props);
this.getLayers = this.getLayers.bind(this);
this.onValuesChange = this.onValuesChange.bind(this);
@@ -78,6 +78,11 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
this.toggleCategory = this.toggleCategory.bind(this);
this.showSingleCategory = this.showSingleCategory.bind(this);
}
componentWillReceiveProps(nextProps) {
if (nextProps.payload.form_data !== this.state.formData) {
this.setState({ ...this.getStateFromProps(nextProps) });
}
}
onValuesChange(values) {
this.setState({
values: Array.isArray(values)
@@ -88,7 +93,7 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
onViewportChange(viewport) {
this.setState({ viewport });
}
getInitialStateFromProps(props, state) {
getStateFromProps(props, state) {
const features = props.payload.data.features || [];
const timestamps = features.map(f => f.__timestamp);
const categories = getCategories(props.formData, features);
@@ -103,7 +108,7 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
// the granularity has to be read from the payload form_data, not the
// props formData which comes from the instantaneous controls state
const granularity = (
props.payload.form_data.time_grain_sqla ||
props.payload.form_data.timeGrainSqla ||
props.payload.form_data.granularity ||
'P1D'
);
@@ -149,8 +154,8 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
features = this.addColor(features, fd);
// Apply user defined data mutator if defined
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
if (fd.jsDataMutator) {
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
features = jsFnMutator(features);
}
@@ -175,8 +180,8 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
return [getLayer(fd, filteredPayload, onAddFilter, setTooltip)];
}
addColor(data, fd) {
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const colorFn = getScale(fd.color_scheme);
const c = fd.colorPicker || { r: 0, g: 0, b: 0, a: 1 };
const colorFn = getScale(fd.colorScheme);
return data.map((d) => {
let color;
if (fd.dimension) {
@@ -224,14 +229,14 @@ export default class CategoricalDeckGLContainer extends React.PureComponent {
viewport={this.state.viewport}
onViewportChange={this.onViewportChange}
mapboxApiAccessToken={this.props.mapboxApiKey}
mapStyle={this.props.formData.mapbox_style}
mapStyle={this.props.formData.mapboxStyle}
setControlValue={this.props.setControlValue}
>
<Legend
categories={this.state.categories}
toggleCategory={this.toggleCategory}
showSingleCategory={this.showSingleCategory}
position={this.props.formData.legend_position}
position={this.props.formData.legendPosition}
/>
</AnimatableDeckGLContainer>
</div>

View File

@@ -56,7 +56,7 @@ class DeckMulti extends React.PureComponent {
const filters = [
...(subslice.form_data.filters || []),
...(formData.filters || []),
...(formData.extra_filters || []),
...(formData.extraFilters || []),
];
const subsliceCopy = {
...subslice,
@@ -70,7 +70,7 @@ class DeckMulti extends React.PureComponent {
endpoint: getExploreLongUrl(subsliceCopy.form_data, 'json'),
})
.then(({ json }) => {
const layer = layerGenerators[subsliceCopy.form_data.viz_type](
const layer = layerGenerators[subsliceCopy.form_data.vizType](
subsliceCopy.form_data,
json,
);
@@ -96,7 +96,7 @@ class DeckMulti extends React.PureComponent {
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={layers}
mapStyle={formData.mapbox_style}
mapStyle={formData.mapboxStyle}
setControlValue={setControlValue}
/>
);

View File

@@ -90,7 +90,7 @@ export function createDeckGLComponent(getLayer, getPoints) {
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
mapStyle={formData.mapboxStyle}
setControlValue={setControlValue}
onViewportChange={this.onViewportChange}
/>);

View File

@@ -31,14 +31,14 @@ function getPoints(data) {
export function getLayer(fd, payload, onAddFilter, setTooltip) {
const data = payload.data.features;
const sc = fd.color_picker;
const tc = fd.target_color_picker;
const sc = fd.colorPicker;
const tc = fd.targetColorPicker;
return new ArcLayer({
id: `path-layer-${fd.slice_id}`,
id: `path-layer-${fd.sliceId}`,
data,
getSourceColor: d => d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a],
getTargetColor: d => d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
strokeWidth: (fd.stroke_width) ? fd.stroke_width : 3,
strokeWidth: (fd.strokeWidth) ? fd.strokeWidth : 3,
...commonLayerProps(fd, setTooltip),
});
}

View File

@@ -77,8 +77,8 @@ const recurseGeoJson = (node, propOverrides, extraProps) => {
export function getLayer(formData, payload, onAddFilter, setTooltip) {
const fd = formData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
const fc = fd.fillColorPicker;
const sc = fd.strokeColorPicker;
const fillColor = [fc.r, fc.g, fc.b, 255 * fc.a];
const strokeColor = [sc.r, sc.g, sc.b, 255 * sc.a];
const propOverrides = {};
@@ -93,19 +93,19 @@ export function getLayer(formData, payload, onAddFilter, setTooltip) {
recurseGeoJson(payload.data, propOverrides);
let jsFnMutator;
if (fd.js_data_mutator) {
if (fd.jsDataMutator) {
// Applying user defined data mutator if defined
jsFnMutator = sandboxedEval(fd.js_data_mutator);
jsFnMutator = sandboxedEval(fd.jsDataMutator);
features = jsFnMutator(features);
}
return new GeoJsonLayer({
id: `geojson-layer-${fd.slice_id}`,
id: `geojson-layer-${fd.sliceId}`,
filled: fd.filled,
data: features,
stroked: fd.stroked,
extruded: fd.extruded,
pointRadiusScale: fd.point_radius_scale,
pointRadiusScale: fd.pointRadiusScale,
...commonLayerProps(fd, setTooltip),
});
}
@@ -145,7 +145,7 @@ function deckGeoJson(props) {
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
mapStyle={formData.mapbox_style}
mapStyle={formData.mapboxStyle}
setControlValue={setControlValue}
/>
);

View File

@@ -24,24 +24,24 @@ import { createDeckGLComponent } from '../../factory';
export function getLayer(formData, payload, onAddFilter, setTooltip) {
const fd = formData;
const c = fd.color_picker;
const c = fd.colorPicker;
let data = payload.data.features.map(d => ({
...d,
color: [c.r, c.g, c.b, 255 * c.a],
}));
if (fd.js_data_mutator) {
if (fd.jsDataMutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
data = jsFnMutator(data);
}
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const aggFunc = getAggFunc(fd.jsAggFunction, p => p.weight);
return new GridLayer({
id: `grid-layer-${fd.slice_id}`,
id: `grid-layer-${fd.sliceId}`,
data,
pickable: true,
cellSize: fd.grid_size,
cellSize: fd.gridSize,
minColor: [0, 0, 0, 0],
extruded: fd.extruded,
maxColor: [c.r, c.g, c.b, 255 * c.a],

View File

@@ -24,23 +24,23 @@ import { createDeckGLComponent } from '../../factory';
export function getLayer(formData, payload, onAddFilter, setTooltip) {
const fd = formData;
const c = fd.color_picker;
const c = fd.colorPicker;
let data = payload.data.features.map(d => ({
...d,
color: [c.r, c.g, c.b, 255 * c.a],
}));
if (fd.js_data_mutator) {
if (fd.jsDataMutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
data = jsFnMutator(data);
}
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const aggFunc = getAggFunc(fd.jsAggFunction, p => p.weight);
return new HexagonLayer({
id: `hex-layer-${fd.slice_id}`,
id: `hex-layer-${fd.sliceId}`,
data,
pickable: true,
radius: fd.grid_size,
radius: fd.gridSize,
minColor: [0, 0, 0, 0],
extruded: fd.extruded,
maxColor: [c.r, c.g, c.b, 255 * c.a],

View File

@@ -23,22 +23,22 @@ import { createDeckGLComponent } from '../../factory';
export function getLayer(formData, payload, onAddFilter, setTooltip) {
const fd = formData;
const c = fd.color_picker;
const c = fd.colorPicker;
const fixedColor = [c.r, c.g, c.b, 255 * c.a];
let data = payload.data.features.map(feature => ({
...feature,
path: feature.path,
width: fd.line_width,
width: fd.lineWidth,
color: fixedColor,
}));
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
if (fd.jsDataMutator) {
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
data = jsFnMutator(data);
}
return new PathLayer({
id: `path-layer-${fd.slice_id}`,
id: `path-layer-${fd.sliceId}`,
data,
rounded: true,
widthScale: 1,

View File

@@ -50,8 +50,8 @@ function getElevation(d, colorScaler) {
export function getLayer(formData, payload, setTooltip, selected, onSelect, filters) {
const fd = formData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
const fc = fd.fillColorPicker;
const sc = fd.strokeColorPicker;
let data = [...payload.data.features];
if (filters != null) {
@@ -60,9 +60,9 @@ export function getLayer(formData, payload, setTooltip, selected, onSelect, filt
});
}
if (fd.js_data_mutator) {
if (fd.jsDataMutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
data = jsFnMutator(data);
}
@@ -76,13 +76,13 @@ export function getLayer(formData, payload, setTooltip, selected, onSelect, filt
// when polygons are selected, reduce the opacity of non-selected polygons
const colorScaler = (d) => {
const baseColor = baseColorScaler(d);
if (selected.length > 0 && selected.indexOf(d[fd.line_column]) === -1) {
if (selected.length > 0 && selected.indexOf(d[fd.lineColumn]) === -1) {
baseColor[3] /= 2;
}
return baseColor;
};
return new PolygonLayer({
id: `path-layer-${fd.slice_id}`,
id: `path-layer-${fd.sliceId}`,
data,
pickable: true,
filled: fd.filled,
@@ -90,7 +90,7 @@ export function getLayer(formData, payload, setTooltip, selected, onSelect, filt
getPolygon: d => d.polygon,
getFillColor: colorScaler,
getLineColor: [sc.r, sc.g, sc.b, 255 * sc.a],
getLineWidth: fd.line_width,
getLineWidth: fd.lineWidth,
extruded: fd.extruded,
getElevation: d => getElevation(d, colorScaler),
elevationScale: fd.multiplier,
@@ -138,7 +138,7 @@ class DeckGLPolygon extends React.Component {
// the granularity has to be read from the payload form_data, not the
// props formData which comes from the instantaneous controls state
const granularity = (
props.payload.form_data.time_grain_sqla ||
props.payload.form_data.timeGrainSqla ||
props.payload.form_data.granularity ||
'P1D'
);
@@ -177,7 +177,7 @@ class DeckGLPolygon extends React.Component {
const selected = [...this.state.selected];
if (doubleClick) {
selected.splice(0, selected.length, polygon);
} else if (formData.toggle_polygons) {
} else if (formData.togglePolygons) {
const i = selected.indexOf(polygon);
if (i === -1) {
selected.push(polygon);
@@ -189,8 +189,8 @@ class DeckGLPolygon extends React.Component {
}
this.setState({ selected, lastClick: now });
if (formData.table_filter) {
onAddFilter(formData.line_column, selected, false, true);
if (formData.tableFilter) {
onAddFilter(formData.lineColumn, selected, false, true);
}
}
onValuesChange(values) {
@@ -249,14 +249,14 @@ class DeckGLPolygon extends React.Component {
viewport={viewport}
onViewportChange={this.onViewportChange}
mapboxApiAccessToken={payload.data.mapboxApiKey}
mapStyle={formData.mapbox_style}
mapStyle={formData.mapboxStyle}
setControlValue={setControlValue}
aggregation
>
{formData.metric !== null &&
<Legend
categories={buckets}
position={formData.legend_position}
position={formData.legendPosition}
/>}
</AnimatableDeckGLContainer>
</div>

View File

@@ -27,24 +27,24 @@ function getPoints(data) {
export function getLayer(fd, payload, onAddFilter, setTooltip) {
const dataWithRadius = payload.data.features.map((d) => {
let radius = unitToRadius(fd.point_unit, d.radius) || 10;
let radius = unitToRadius(fd.pointUnit, d.radius) || 10;
if (fd.multiplier) {
radius *= fd.multiplier;
}
if (d.color) {
return { ...d, radius };
}
const c = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
const c = fd.colorPicker || { r: 0, g: 0, b: 0, a: 1 };
const color = [c.r, c.g, c.b, c.a * 255];
return { ...d, radius, color };
});
return new ScatterplotLayer({
id: `scatter-layer-${fd.slice_id}`,
id: `scatter-layer-${fd.sliceId}`,
data: dataWithRadius,
fp64: true,
radiusMinPixels: fd.min_radius || null,
radiusMaxPixels: fd.max_radius || null,
radiusMinPixels: fd.minRadius || null,
radiusMaxPixels: fd.maxRadius || null,
outline: false,
...commonLayerProps(fd, setTooltip),
});

View File

@@ -32,15 +32,15 @@ function getPoints(data) {
export function getLayer(formData, payload, onAddFilter, setTooltip, filters) {
const fd = formData;
const c = fd.color_picker;
const c = fd.colorPicker;
let data = payload.data.features.map(d => ({
...d,
color: [c.r, c.g, c.b, 255 * c.a],
}));
if (fd.js_data_mutator) {
if (fd.jsDataMutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
const jsFnMutator = sandboxedEval(fd.jsDataMutator);
data = jsFnMutator(data);
}
@@ -53,10 +53,10 @@ export function getLayer(formData, payload, onAddFilter, setTooltip, filters) {
// Passing a layer creator function instead of a layer since the
// layer needs to be regenerated at each render
return new ScreenGridLayer({
id: `screengrid-layer-${fd.slice_id}`,
id: `screengrid-layer-${fd.sliceId}`,
data,
pickable: true,
cellSizePixels: fd.grid_size,
cellSizePixels: fd.gridSize,
minColor: [c.r, c.g, c.b, 0],
maxColor: [c.r, c.g, c.b, 255 * c.a],
outline: false,
@@ -102,7 +102,7 @@ class DeckGLScreenGrid extends React.PureComponent {
// the granularity has to be read from the payload form_data, not the
// props formData which comes from the instantaneous controls state
const granularity = (
props.payload.form_data.time_grain_sqla ||
props.payload.form_data.timeGrainSqla ||
props.payload.form_data.granularity ||
'P1D'
);
@@ -176,7 +176,7 @@ class DeckGLScreenGrid extends React.PureComponent {
viewport={this.state.viewport}
onViewportChange={this.onViewportChange}
mapboxApiAccessToken={payload.data.mapboxApiKey}
mapStyle={formData.mapbox_style}
mapStyle={formData.mapboxStyle}
setControlValue={setControlValue}
aggregation
/>

View File

@@ -54,13 +54,13 @@ export function commonLayerProps(formData, setTooltip, onSelect) {
const fd = formData;
let onHover;
let tooltipContentGenerator;
if (fd.js_tooltip) {
tooltipContentGenerator = sandboxedEval(fd.js_tooltip);
} else if (fd.line_column && fd.metric && ['geohash', 'zipcode'].indexOf(fd.line_type) >= 0) {
if (fd.jsTooltip) {
tooltipContentGenerator = sandboxedEval(fd.jsTooltip);
} else if (fd.lineColumn && fd.metric && ['geohash', 'zipcode'].indexOf(fd.lineType) >= 0) {
const metricLabel = fd.metric.label || fd.metric;
tooltipContentGenerator = o => (
<div>
<div>{fd.line_column}: <strong>{o.object[fd.line_column]}</strong></div>
<div>{fd.lineColumn}: <strong>{o.object[fd.lineColumn]}</strong></div>
{fd.metric &&
<div>{metricLabel}: <strong>{o.object[metricLabel]}</strong></div>}
</div>);
@@ -71,7 +71,7 @@ export function commonLayerProps(formData, setTooltip, onSelect) {
setTooltip({
content: tooltipContentGenerator(o),
x: o.x,
y: o.y,
y: o.y + 30,
});
} else {
setTooltip(null);
@@ -79,13 +79,13 @@ export function commonLayerProps(formData, setTooltip, onSelect) {
};
}
let onClick;
if (fd.js_onclick_href) {
if (fd.jsOnclickHref) {
onClick = (o) => {
const href = sandboxedEval(fd.js_onclick_href)(o);
const href = sandboxedEval(fd.jsOnclickHref)(o);
window.open(href);
};
} else if (fd.table_filter && onSelect !== undefined) {
onClick = o => onSelect(o.object[fd.line_column]);
} else if (fd.tableFilter && onSelect !== undefined) {
onClick = o => onSelect(o.object[fd.lineColumn]);
}
return {
onClick,

View File

@@ -24,8 +24,8 @@ import { hexToRGB } from '../../modules/colors';
const DEFAULT_NUM_BUCKETS = 10;
export function getBreakPoints({
break_points: formDataBreakPoints,
num_buckets: formDataNumBuckets,
breakPoints: formDataBreakPoints,
numBuckets: formDataNumBuckets,
}, features, accessor) {
if (!features) {
return [];
@@ -46,15 +46,15 @@ export function getBreakPoints({
}
export function getBreakPointColorScaler({
break_points: formDataBreakPoints,
num_buckets: formDataNumBuckets,
linear_color_scheme: linearColorScheme,
breakPoints: formDataBreakPoints,
numBuckets: formDataNumBuckets,
linearColorScheme,
opacity,
}, features, accessor) {
const breakPoints = formDataBreakPoints || formDataNumBuckets
? getBreakPoints({
break_points: formDataBreakPoints,
num_buckets: formDataNumBuckets,
breakPoints: formDataBreakPoints,
numBuckets: formDataNumBuckets,
}, features, accessor)
: null;
const colorScheme = Array.isArray(linearColorScheme)

View File

@@ -1,58 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import srcdoc from 'srcdoc-polyfill';
import './markup.css';
function markupWidget(slice, payload) {
const { selector } = slice;
const height = slice.height();
const headerHeight = slice.headerHeight();
const vizType = slice.props.vizType;
const { data } = payload;
const container = document.querySelector(selector);
container.style.overflow = 'auto';
// markup height is slice height - (marginTop + marginBottom)
const iframeHeight = vizType === 'separator'
? height - 20
: height + headerHeight;
const html = `
<html>
<head>
${data.theme_css.map(
href => `<link rel="stylesheet" type="text/css" href="${href}" />`,
)}
</head>
<body style="background-color: transparent;">
${data.html}
</body>
</html>`;
const iframe = document.createElement('iframe');
iframe.setAttribute('frameborder', 0);
iframe.setAttribute('height', iframeHeight);
iframe.setAttribute('sandbox', 'allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation');
container.appendChild(iframe);
srcdoc.set(iframe, html);
}
export default markupWidget;

View File

@@ -94,9 +94,9 @@ class LineMulti extends React.Component {
const combinedFormData = {
...subslice.form_data,
filters: (subsliceFormData.filters || [])
.concat(filters || [])
.concat(extraFilters || []),
.concat(filters || []),
time_range: timeRange,
extra_filters: extraFilters || [],
};
const addPrefix = prefixMetricWithSliceName;
return getJson(getExploreLongUrl(combinedFormData, 'json'))

View File

@@ -641,15 +641,15 @@ function nvd3Vis(element, props) {
// If x bounds are shown, we need a right margin
margins.right = Math.max(20, maxXAxisLabelHeight / 2) + marginPad;
}
if (staggerLabels) {
margins.bottom = 40;
} else {
if (xLabelRotation === 45) {
margins.bottom = (
maxXAxisLabelHeight * Math.sin(Math.PI * xLabelRotation / 180)
) + marginPad;
margins.right = (
maxXAxisLabelHeight * Math.cos(Math.PI * xLabelRotation / 180)
) + marginPad;
} else if (staggerLabels) {
margins.bottom = 40;
}
if (isVizTypes(['dual_line', 'line_multi'])) {
@@ -711,8 +711,9 @@ function nvd3Vis(element, props) {
.attr('height', height)
.call(chart);
// on scroll, hide tooltips. throttle to only 4x/second.
window.addEventListener('scroll', throttle(hideTooltips, 250));
// On scroll, hide (not remove) tooltips so they can reappear on hover.
// Throttle to only 4x/second.
window.addEventListener('scroll', throttle(() => hideTooltips(false), 250));
// The below code should be run AFTER rendering because chart is updated in call()
if (isTimeSeries && activeAnnotationLayers.length > 0) {
@@ -933,10 +934,10 @@ function nvd3Vis(element, props) {
return chart;
};
// hide tooltips before rendering chart, if the chart is being re-rendered sometimes
// Remove tooltips before rendering chart, if the chart is being re-rendered sometimes
// there are left over tooltips in the dom,
// this will clear them before rendering the chart again.
hideTooltips();
hideTooltips(true);
nv.addGraph(drawGraph);
}

View File

@@ -165,10 +165,19 @@ export function generateBubbleTooltipContent({
return s;
}
export function hideTooltips() {
const target = document.querySelector('.nvtooltip');
if (target) {
target.style.opacity = 0;
// shouldRemove indicates whether the nvtooltips should be removed from the DOM
export function hideTooltips(shouldRemove) {
const targets = document.querySelectorAll('.nvtooltip');
if (targets.length > 0) {
// Only set opacity to 0 when hiding tooltips so they would reappear
// on hover, which sets the opacity to 1
for (const t of targets) {
if (shouldRemove) {
t.remove();
} else {
t.style.opacity = 0;
}
}
}
}

View File

@@ -30,7 +30,9 @@ import EventFlowChartPlugin from '../EventFlow/EventFlowChartPlugin';
import ForceDirectedChartPlugin from '../ForceDirected/ForceDirectedChartPlugin';
import HeatmapChartPlugin from '../Heatmap/HeatmapChartPlugin';
import HorizonChartPlugin from '../Horizon/HorizonChartPlugin';
import IframeChartPlugin from '../Iframe/IframeChartPlugin';
import LineMultiChartPlugin from '../nvd3/LineMulti/LineMultiChartPlugin';
import MarkupChartPlugin from '../Markup/MarkupChartPlugin';
import PairedTTestChartPlugin from '../PairedTTest/PairedTTestChartPlugin';
import ParallelCoordinatesChartPlugin from '../ParallelCoordinates/ParallelCoordinatesChartPlugin';
import RoseChartPlugin from '../Rose/RoseChartPlugin';
@@ -57,7 +59,10 @@ export default class LegacyChartPreset extends Preset {
new ForceDirectedChartPlugin().configure({ key: 'directed_force' }),
new HeatmapChartPlugin().configure({ key: 'heatmap' }),
new HorizonChartPlugin().configure({ key: 'horizon' }),
new IframeChartPlugin().configure({ key: 'iframe' }),
new LineMultiChartPlugin().configure({ key: 'line_multi' }),
new MarkupChartPlugin().configure({ key: 'markup' }),
new MarkupChartPlugin().configure({ key: 'separator' }),
new PairedTTestChartPlugin().configure({ key: 'paired_ttest' }),
new ParallelCoordinatesChartPlugin().configure({ key: 'para' }),
new RoseChartPlugin().configure({ key: 'rose' }),

View File

@@ -71,7 +71,7 @@ class DashboardTable extends React.PureComponent {
{this.state.dashboards.map(o => (
<Tr key={o.id}>
<Td column="dashboard" value={o.dashboard_title}>
{o.dashboard_title}
<a href={o.url}>{o.dashboard_title}</a>
</Td>
<Td column="creator" value={o.changed_by_name}>
{unsafe(o.creator)}

View File

@@ -14,30 +14,247 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=R
# pylint: disable=C,R,W
from datetime import datetime, timedelta
import logging
import pickle as pkl
import traceback
from typing import Dict, List
import numpy as np
import pandas as pd
from superset import app, cache
from superset import db
from superset.connectors.connector_registry import ConnectorRegistry
from superset.utils import core as utils
from superset.utils.core import DTTM_ALIAS
from .query_object import QueryObject
config = app.config
stats_logger = config.get('STATS_LOGGER')
class QueryContext:
"""
The query context contains the query object and additional fields necessary
to retrieve the data payload for a given viz.
"""
default_fillna = 0
cache_type = 'df'
enforce_numerical_metrics = True
# TODO: Type datasource and query_object dictionary with TypedDict when it becomes
# a vanilla python type https://github.com/python/mypy/issues/5288
def __init__(
self,
datasource: Dict,
queries: List[Dict],
force: bool = False,
custom_cache_timeout: int = None,
):
self.datasource = ConnectorRegistry.get_datasource(datasource.get('type'),
int(datasource.get('id')),
db.session)
self.queries = list(map(lambda query_obj: QueryObject(**query_obj), queries))
def get_data(self):
raise NotImplementedError()
self.force = force
self.custom_cache_timeout = custom_cache_timeout
self.enforce_numerical_metrics = True
def get_query_result(self, query_object):
"""Returns a pandas dataframe based on the query object"""
# Here, we assume that all the queries will use the same datasource, which is
# is a valid assumption for current setting. In a long term, we may or maynot
# support multiple queries from different data source.
timestamp_format = None
if self.datasource.type == 'table':
dttm_col = self.datasource.get_col(query_object.granularity)
if dttm_col:
timestamp_format = dttm_col.python_date_format
# The datasource here can be different backend but the interface is common
result = self.datasource.query(query_object.to_dict())
df = result.df
# Transform the timestamp we received from database to pandas supported
# datetime format. If no python_date_format is specified, the pattern will
# be considered as the default ISO date format
# If the datetime format is unix, the parse will use the corresponding
# parsing logic
if df is not None and not df.empty:
if DTTM_ALIAS in df.columns:
if timestamp_format in ('epoch_s', 'epoch_ms'):
# Column has already been formatted as a timestamp.
df[DTTM_ALIAS] = df[DTTM_ALIAS].apply(pd.Timestamp)
else:
df[DTTM_ALIAS] = pd.to_datetime(
df[DTTM_ALIAS], utc=False, format=timestamp_format)
if self.datasource.offset:
df[DTTM_ALIAS] += timedelta(hours=self.datasource.offset)
df[DTTM_ALIAS] += query_object.time_shift
if self.enforce_numerical_metrics:
self.df_metrics_to_num(df, query_object)
df.replace([np.inf, -np.inf], np.nan)
df = self.handle_nulls(df)
return {
'query': result.query,
'status': result.status,
'error_message': result.error_message,
'df': df,
}
def df_metrics_to_num(self, df, query_object):
"""Converting metrics to numeric when pandas.read_sql cannot"""
metrics = [metric for metric in query_object.metrics]
for col, dtype in df.dtypes.items():
if dtype.type == np.object_ and col in metrics:
df[col] = pd.to_numeric(df[col], errors='coerce')
def handle_nulls(self, df):
fillna = self.get_fillna_for_columns(df.columns)
return df.fillna(fillna)
def get_fillna_for_col(self, col):
"""Returns the value to use as filler for a specific Column.type"""
if col and col.is_string:
return ' NULL'
return self.default_fillna
def get_fillna_for_columns(self, columns=None):
"""Returns a dict or scalar that can be passed to DataFrame.fillna"""
if columns is None:
return self.default_fillna
columns_dict = {col.column_name: col for col in self.datasource.columns}
fillna = {
c: self.get_fillna_for_col(columns_dict.get(c))
for c in columns
}
return fillna
def get_data(self, df):
return df.to_dict(orient='records')
def get_single_payload(self, query_obj):
"""Returns a payload of metadata and data"""
payload = self.get_df_payload(query_obj)
df = payload.get('df')
status = payload.get('status')
if status != utils.QueryStatus.FAILED:
if df is not None and df.empty:
payload['error'] = 'No data'
else:
payload['data'] = self.get_data(df)
if 'df' in payload:
del payload['df']
return payload
def get_payload(self):
"""Get all the paylaods from the arrays"""
return [self.get_single_payload(query_ojbect) for query_ojbect in self.queries]
@property
def cache_timeout(self):
if self.custom_cache_timeout is not None:
return self.custom_cache_timeout
if self.datasource.cache_timeout is not None:
return self.datasource.cache_timeout
if (
hasattr(self.datasource, 'database') and
self.datasource.database.cache_timeout) is not None:
return self.datasource.database.cache_timeout
return config.get('CACHE_DEFAULT_TIMEOUT')
def get_df_payload(self, query_obj, **kwargs):
"""Handles caching around the df paylod retrieval"""
cache_key = query_obj.cache_key(
datasource=self.datasource.uid, **kwargs) if query_obj else None
logging.info('Cache key: {}'.format(cache_key))
is_loaded = False
stacktrace = None
df = None
cached_dttm = datetime.utcnow().isoformat().split('.')[0]
cache_value = None
status = None
query = ''
error_message = None
if cache_key and cache and not self.force:
cache_value = cache.get(cache_key)
if cache_value:
stats_logger.incr('loaded_from_cache')
try:
cache_value = pkl.loads(cache_value)
df = cache_value['df']
query = cache_value['query']
status = utils.QueryStatus.SUCCESS
is_loaded = True
except Exception as e:
logging.exception(e)
logging.error('Error reading cache: ' +
utils.error_msg_from_exception(e))
logging.info('Serving from cache')
if query_obj and not is_loaded:
try:
query_result = self.get_query_result(query_obj)
status = query_result['status']
query = query_result['query']
error_message = query_result['error_message']
df = query_result['df']
if status != utils.QueryStatus.FAILED:
stats_logger.incr('loaded_from_source')
is_loaded = True
except Exception as e:
logging.exception(e)
if not error_message:
error_message = '{}'.format(e)
status = utils.QueryStatus.FAILED
stacktrace = traceback.format_exc()
if (
is_loaded and
cache_key and
cache and
status != utils.QueryStatus.FAILED):
try:
cache_value = dict(
dttm=cached_dttm,
df=df if df is not None else None,
query=query,
)
cache_value = pkl.dumps(
cache_value, protocol=pkl.HIGHEST_PROTOCOL)
logging.info('Caching {} chars at key {}'.format(
len(cache_value), cache_key))
stats_logger.incr('set_cache_key')
cache.set(
cache_key,
cache_value,
timeout=self.cache_timeout)
except Exception as e:
# cache.set call can fail if the backend is down or if
# the key is too large or whatever other reasons
logging.warning('Could not cache key {}'.format(cache_key))
logging.exception(e)
cache.delete(cache_key)
return {
'cache_key': cache_key,
'cached_dttm': cache_value['dttm'] if cache_value is not None else None,
'cache_timeout': self.cache_timeout,
'df': df,
'error': error_message,
'is_cached': cache_key is not None,
'query': query,
'status': status,
'stacktrace': stacktrace,
'rowcount': len(df.index) if df is not None else 0,
}

View File

@@ -15,15 +15,17 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=R
from typing import Dict, List, Optional
import hashlib
from typing import Dict, List, Optional, Union
import simplejson as json
from superset import app
from superset.utils import core as utils
# TODO: Type Metrics dictionary with TypedDict when it becomes a vanilla python type
# https://github.com/python/mypy/issues/5288
Metric = Dict
class QueryObject:
"""
@@ -33,31 +35,87 @@ class QueryObject:
def __init__(
self,
granularity: str,
metrics: List[Union[Dict, str]],
groupby: List[str] = None,
metrics: List[Metric] = None,
filters: List[str] = None,
time_range: Optional[str] = None,
time_shift: Optional[str] = None,
is_timeseries: bool = False,
timeseries_limit: int = 0,
row_limit: int = app.config.get('ROW_LIMIT'),
limit: int = 0,
timeseries_limit_metric: Optional[Metric] = None,
timeseries_limit_metric: Optional[Dict] = None,
order_desc: bool = True,
extras: Optional[Dict] = None,
prequeries: Optional[Dict] = None,
is_prequery: bool = False,
columns: List[str] = None,
orderby: List[List] = None,
):
self.granularity = granularity
self.from_dttm, self.to_dttm = utils.get_since_until(time_range, time_shift)
self.is_timeseries = is_timeseries
self.groupby = groupby or []
self.metrics = metrics or []
self.filter = filters or []
self.time_range = time_range
self.time_shift = utils.parse_human_timedelta(time_shift)
self.groupby = groupby if groupby is not None else []
# Temporal solution for backward compatability issue
# due the new format of non-ad-hoc metric.
self.metrics = [metric if 'expressionType' in metric else metric['label']
for metric in metrics]
self.row_limit = row_limit
self.timeseries_limit = int(limit)
self.filter = filters if filters is not None else []
self.timeseries_limit = timeseries_limit
self.timeseries_limit_metric = timeseries_limit_metric
self.order_desc = order_desc
self.prequeries = []
self.is_prequery = False
self.extras = extras
self.prequeries = prequeries
self.is_prequery = is_prequery
self.extras = extras if extras is not None else {}
self.columns = columns if columns is not None else []
self.orderby = orderby if orderby is not None else []
def to_dict(self):
raise NotImplementedError()
query_object_dict = {
'granularity': self.granularity,
'from_dttm': self.from_dttm,
'to_dttm': self.to_dttm,
'is_timeseries': self.is_timeseries,
'groupby': self.groupby,
'metrics': self.metrics,
'row_limit': self.row_limit,
'filter': self.filter,
'timeseries_limit': self.timeseries_limit,
'timeseries_limit_metric': self.timeseries_limit_metric,
'order_desc': self.order_desc,
'prequeries': self.prequeries,
'is_prequery': self.is_prequery,
'extras': self.extras,
'columns': self.columns,
'orderby': self.orderby,
}
return query_object_dict
def cache_key(self, **extra):
"""
The cache key is made out of the key/values in `query_obj`, plus any
other key/values in `extra`
We remove datetime bounds that are hard values, and replace them with
the use-provided inputs to bounds, which may be time-relative (as in
"5 days ago" or "now").
"""
cache_dict = self.to_dict()
cache_dict.update(extra)
for k in ['from_dttm', 'to_dttm']:
del cache_dict[k]
if self.time_range:
cache_dict['time_range'] = self.time_range
json_data = self.json_dumps(cache_dict, sort_keys=True)
return hashlib.md5(json_data.encode('utf-8')).hexdigest()
def json_dumps(self, obj, sort_keys=False):
return json.dumps(
obj,
default=utils.json_int_dttm_ser,
ignore_nan=True,
sort_keys=sort_keys,
)

View File

@@ -110,6 +110,7 @@ APP_NAME = 'Superset'
# Uncomment to setup an App icon
APP_ICON = '/static/assets/images/superset-logo@2x.png'
APP_ICON_WIDTH = 126
# Druid query timezone
# tz.tzutc() : Using utc timezone
@@ -325,6 +326,9 @@ DEFAULT_SQLLAB_LIMIT = 1000
# Maximum number of tables/views displayed in the dropdown window in SQL Lab.
MAX_TABLE_NAMES = 3000
# Adds a warning message on sqllab save query modal.
SQLLAB_SAVE_WARNING_MESSAGE = None
# If defined, shows this text in an alert-warning box in the navbar
# one example use case may be "STAGING" to make it clear that this is
# not the production version of the site.
@@ -558,6 +562,9 @@ WEBDRIVER_CONFIGURATION = {}
# The base URL to query for accessing the user interface
WEBDRIVER_BASEURL = 'http://0.0.0.0:8080/'
# Send user to a link where they can report bugs
BUG_REPORT_URL = None
try:
if CONFIG_PATH_ENV_VAR in os.environ:

View File

@@ -422,6 +422,7 @@ class SqlaTable(Model, BaseDatasource):
d['time_grain_sqla'] = grains
d['main_dttm_col'] = self.main_dttm_col
d['fetch_values_predicate'] = self.fetch_values_predicate
d['template_params'] = self.template_params
return d
def values_for_column(self, column_name, limit=10000):
@@ -729,15 +730,11 @@ class SqlaTable(Model, BaseDatasource):
ob = inner_main_metric_expr
if timeseries_limit_metric:
if utils.is_adhoc_metric(timeseries_limit_metric):
ob = self.adhoc_metric_to_sqla(timeseries_limit_metric, cols)
elif timeseries_limit_metric in metrics_dict:
timeseries_limit_metric = metrics_dict.get(
timeseries_limit_metric,
)
ob = timeseries_limit_metric.get_sqla_col()
else:
raise Exception(_("Metric '{}' is not valid".format(m)))
ob = self._get_timeseries_orderby(
timeseries_limit_metric,
metrics_dict,
cols,
)
direction = desc if order_desc else asc
subq = subq.order_by(direction(ob))
subq = subq.limit(timeseries_limit)
@@ -752,6 +749,16 @@ class SqlaTable(Model, BaseDatasource):
tbl = tbl.join(subq.alias(), and_(*on_clause))
else:
if timeseries_limit_metric:
orderby = [(
self._get_timeseries_orderby(
timeseries_limit_metric,
metrics_dict,
cols,
),
False,
)]
# run subquery to get top groups
subquery_obj = {
'prequeries': prequeries,
@@ -780,6 +787,19 @@ class SqlaTable(Model, BaseDatasource):
return qry.select_from(tbl)
def _get_timeseries_orderby(self, timeseries_limit_metric, metrics_dict, cols):
if utils.is_adhoc_metric(timeseries_limit_metric):
ob = self.adhoc_metric_to_sqla(timeseries_limit_metric, cols)
elif timeseries_limit_metric in metrics_dict:
timeseries_limit_metric = metrics_dict.get(
timeseries_limit_metric,
)
ob = timeseries_limit_metric.get_sqla_col()
else:
raise Exception(_("Metric '{}' is not valid".format(timeseries_limit_metric)))
return ob
def _get_top_groups(self, df, dimensions):
cols = {col.column_name: col for col in self.columns}
groups = []

View File

@@ -929,16 +929,16 @@ class PrestoEngineSpec(BaseEngineSpec):
except Exception:
# table is not partitioned
return False
for c in columns:
if c.get('name') == col_name:
return qry.where(Column(col_name) == value)
if value is not None:
for c in columns:
if c.get('name') == col_name:
return qry.where(Column(col_name) == value)
return False
@classmethod
def _latest_partition_from_df(cls, df):
recs = df.to_records(index=False)
if recs:
return recs[0][0]
if not df.empty:
return df.to_records(index=False)[0][0]
@classmethod
def latest_partition(cls, table_name, schema, database, show_first=False):
@@ -955,7 +955,7 @@ class PrestoEngineSpec(BaseEngineSpec):
:type show_first: bool
>>> latest_partition('foo_table')
'2018-01-01'
('ds', '2018-01-01')
"""
indexes = database.get_indexes(table_name, schema)
if len(indexes[0]['column_names']) < 1:
@@ -1272,9 +1272,10 @@ class HiveEngineSpec(PrestoEngineSpec):
except Exception:
# table is not partitioned
return False
for c in columns:
if c.get('name') == col_name:
return qry.where(Column(col_name) == value)
if value is not None:
for c in columns:
if c.get('name') == col_name:
return qry.where(Column(col_name) == value)
return False
@classmethod
@@ -1285,7 +1286,8 @@ class HiveEngineSpec(PrestoEngineSpec):
@classmethod
def _latest_partition_from_df(cls, df):
"""Hive partitions look like ds={partition name}"""
return df.ix[:, 0].max().split('=')[1]
if not df.empty:
return df.ix[:, 0].max().split('=')[1]
@classmethod
def _partition_query(

View File

@@ -0,0 +1,191 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""form nullable
Revision ID: c617da68de7d
Revises: 18dc26817ad2
Create Date: 2018-07-19 23:41:32.631556
"""
# revision identifiers, used by Alembic.
revision = 'c617da68de7d'
down_revision = '18dc26817ad2'
from alembic import op
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Text
from superset import db
from superset.utils.core import MediumText
Base = declarative_base()
class BaseColumnMixin(object):
id = Column(Integer, primary_key=True)
column_name = Column(String(255))
description = Column(Text)
type = Column(String(32))
verbose_name = Column(String(1024))
class BaseDatasourceMixin(object):
id = Column(Integer, primary_key=True)
description = Column(Text)
class BaseMetricMixin(object):
id = Column(Integer, primary_key=True)
d3format = Column(String(128))
description = Column(Text)
metric_name = Column(String(512))
metric_type = Column(String(32))
verbose_name = Column(String(1024))
warning_text = Column(Text)
class Annotation(Base):
__tablename__ = 'annotation'
id = Column(Integer, primary_key=True)
long_descr = Column(Text)
json_metadata = Column(Text)
short_descr = Column(String(500))
class Dashboard(Base):
__tablename__ = 'dashboards'
id = Column(Integer, primary_key=True)
css = Column(Text)
dashboard_title = Column(String(500))
description = Column(Text)
json_metadata = Column(Text)
position_json = Column(MediumText())
slug = Column(String(255))
class Database(Base):
__tablename__ = 'dbs'
id = Column(Integer, primary_key=True)
database_name = Column(String(250))
extra = Column(Text)
force_ctas_schema = Column(String(250))
sqlalchemy_uri = Column(String(1024))
verbose_name = Column(String(250))
class DruidCluster(Base):
__tablename__ = 'clusters'
id = Column(Integer, primary_key=True)
broker_host = Column(String(255))
broker_endpoint = Column(String(255))
cluster_name = Column(String(250))
verbose_name = Column(String(250))
class DruidColumn(BaseColumnMixin, Base):
__tablename__ = 'columns'
dimension_spec_json = Column(Text)
class DruidDatasource(BaseDatasourceMixin, Base):
__tablename__ = 'datasources'
datasource_name = Column(String(255))
default_endpoint = Column(Text)
fetch_values_from = Column(String(100))
class DruidMetric(BaseMetricMixin, Base):
__tablename__ = 'metrics'
json = Column(Text)
class Slice(Base):
__tablename__ = 'slices'
id = Column(Integer, primary_key=True)
description = Column(Text)
params = Column(Text)
slice_name = Column(String(250))
viz_type = Column(String(250))
class SqlaTable(BaseDatasourceMixin, Base):
__tablename__ = 'tables'
default_endpoint = Column(MediumText())
fetch_values_predicate = Column(String(1000))
main_dttm_col = Column(String(250))
schema = Column(String(255))
sql = Column(Text)
table_name = Column(String(250))
template_params = Column(Text)
class SqlMetric(BaseMetricMixin, Base):
__tablename__ = 'sql_metrics'
expression = Column(Text)
class TableColumn(BaseColumnMixin, Base):
__tablename__ = 'table_columns'
database_expression = Column(String(255))
expression = Column(Text)
python_date_format = Column(String(255))
def upgrade():
bind = op.get_bind()
session = db.Session(bind=bind)
tables = [
Annotation,
Dashboard,
Database,
DruidCluster,
DruidColumn,
DruidDatasource,
DruidMetric,
Slice,
SqlaTable,
SqlMetric,
TableColumn,
]
for table in tables:
for record in session.query(table).all():
for col in record.__table__.columns.values():
if not col.primary_key:
if getattr(record, col.name) == '':
setattr(record, col.name, None)
session.commit()
session.close()
def downgrade():
pass

View File

@@ -0,0 +1,75 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Add implicit tags
Revision ID: c82ee8a39623
Revises: c18bd4186f15
Create Date: 2018-07-26 11:10:23.653524
"""
# revision identifiers, used by Alembic.
revision = 'c82ee8a39623'
down_revision = 'c617da68de7d'
from alembic import op
from sqlalchemy import Column, Enum, Integer, ForeignKey, String
from sqlalchemy.ext.declarative import declarative_base
from superset.models.helpers import AuditMixinNullable
from superset.models.tags import (
ObjectTypes,
TagTypes,
)
Base = declarative_base()
class Tag(Base, AuditMixinNullable):
"""A tag attached to an object (query, chart or dashboard)."""
__tablename__ = 'tag'
id = Column(Integer, primary_key=True)
name = Column(String(250), unique=True)
type = Column(Enum(TagTypes))
class TaggedObject(Base, AuditMixinNullable):
__tablename__ = 'tagged_object'
id = Column(Integer, primary_key=True)
tag_id = Column(Integer, ForeignKey('tag.id'))
object_id = Column(Integer)
object_type = Column(Enum(ObjectTypes))
class User(Base):
"""Declarative class to do query in upgrade"""
__tablename__ = 'ab_user'
id = Column(Integer, primary_key=True)
def upgrade():
bind = op.get_bind()
Tag.__table__.create(bind)
TaggedObject.__table__.create(bind)
def downgrade():
op.drop_table('tag')
op.drop_table('tagged_object')

View File

@@ -48,6 +48,7 @@ from superset import app, db, db_engine_specs, security_manager
from superset.connectors.connector_registry import ConnectorRegistry
from superset.legacy import update_time_range
from superset.models.helpers import AuditMixinNullable, ImportMixin
from superset.models.tags import ChartUpdater, DashboardUpdater, FavStarUpdater
from superset.models.user_attributes import UserAttribute
from superset.utils import (
cache as cache_util,
@@ -359,6 +360,13 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
session.flush()
return slc_to_import.id
@property
def url(self):
return (
'/superset/explore/?form_data=%7B%22slice_id%22%3A%20{0}%7D'
.format(self.id)
)
sqla.event.listen(Slice, 'before_insert', set_related_perm)
sqla.event.listen(Slice, 'before_update', set_related_perm)
@@ -1251,3 +1259,14 @@ class DatasourceAccessRequest(Model, AuditMixinNullable):
href = '{} Role'.format(r.name)
action_list = action_list + '<li>' + href + '</li>'
return '<ul>' + action_list + '</ul>'
# events for updating tags
sqla.event.listen(Slice, 'after_insert', ChartUpdater.after_insert)
sqla.event.listen(Slice, 'after_update', ChartUpdater.after_update)
sqla.event.listen(Slice, 'after_delete', ChartUpdater.after_delete)
sqla.event.listen(Dashboard, 'after_insert', DashboardUpdater.after_insert)
sqla.event.listen(Dashboard, 'after_update', DashboardUpdater.after_update)
sqla.event.listen(Dashboard, 'after_delete', DashboardUpdater.after_delete)
sqla.event.listen(FavStar, 'after_insert', FavStarUpdater.after_insert)
sqla.event.listen(FavStar, 'after_delete', FavStarUpdater.after_delete)

View File

@@ -29,6 +29,7 @@ from sqlalchemy.orm import backref, relationship
from superset import security_manager
from superset.models.helpers import AuditMixinNullable, ExtraJSONMixin
from superset.models.tags import QueryUpdater
from superset.utils.core import QueryStatus, user_label
@@ -173,3 +174,12 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin):
@property
def sqlalchemy_uri(self):
return self.database.sqlalchemy_uri
def url(self):
return '/superset/sqllab?savedQueryId={0}'.format(self.id)
# events for updating tags
sqla.event.listen(SavedQuery, 'after_insert', QueryUpdater.after_insert)
sqla.event.listen(SavedQuery, 'after_update', QueryUpdater.after_update)
sqla.event.listen(SavedQuery, 'after_delete', QueryUpdater.after_delete)

244
superset/models/tags.py Normal file
View File

@@ -0,0 +1,244 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=C,R,W,no-init
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import enum
from flask_appbuilder import Model
from sqlalchemy import Column, Enum, ForeignKey, Integer, String
from sqlalchemy.orm import relationship, sessionmaker
from sqlalchemy.orm.exc import NoResultFound
from superset.models.helpers import AuditMixinNullable
Session = sessionmaker(autoflush=False)
class TagTypes(enum.Enum):
"""
Types for tags.
Objects (queries, charts and dashboards) will have with implicit tags based
on metadata: types, owners and who favorited them. This way, user "alice"
can find all their objects by querying for the tag `owner:alice`.
"""
# explicit tags, added manually by the owner
custom = 1
# implicit tags, generated automatically
type = 2
owner = 3
favorited_by = 4
class ObjectTypes(enum.Enum):
"""Object types."""
query = 1
chart = 2
dashboard = 3
class Tag(Model, AuditMixinNullable):
"""A tag attached to an object (query, chart or dashboard)."""
__tablename__ = 'tag'
id = Column(Integer, primary_key=True) # pylint: disable=invalid-name
name = Column(String(250), unique=True)
type = Column(Enum(TagTypes))
class TaggedObject(Model, AuditMixinNullable):
"""An association between an object and a tag."""
__tablename__ = 'tagged_object'
id = Column(Integer, primary_key=True) # pylint: disable=invalid-name
tag_id = Column(Integer, ForeignKey('tag.id'))
object_id = Column(Integer)
object_type = Column(Enum(ObjectTypes))
tag = relationship('Tag')
def get_tag(name, session, type_):
try:
tag = session.query(Tag).filter_by(name=name, type=type_).one()
except NoResultFound:
tag = Tag(name=name, type=type_)
session.add(tag)
session.commit()
return tag
def get_object_type(class_name):
mapping = {
'slice': ObjectTypes.chart,
'dashboard': ObjectTypes.dashboard,
'query': ObjectTypes.query,
}
try:
return mapping[class_name.lower()]
except KeyError:
raise Exception('No mapping found for {0}'.format(class_name))
class ObjectUpdater(object):
object_type = None
@classmethod
def get_owners_ids(cls, target):
raise NotImplementedError('Subclass should implement `get_owners_ids`')
@classmethod
def _add_owners(cls, session, target):
for owner_id in cls.get_owners_ids(target):
name = 'owner:{0}'.format(owner_id)
tag = get_tag(name, session, TagTypes.owner)
tagged_object = TaggedObject(
tag_id=tag.id,
object_id=target.id,
object_type=ObjectTypes.chart,
)
session.add(tagged_object)
@classmethod
def after_insert(cls, mapper, connection, target):
# pylint: disable=unused-argument
session = Session(bind=connection)
# add `owner:` tags
cls._add_owners(session, target)
# add `type:` tags
tag = get_tag(
'type:{0}'.format(cls.object_type), session, TagTypes.type)
tagged_object = TaggedObject(
tag_id=tag.id,
object_id=target.id,
object_type=ObjectTypes.query,
)
session.add(tagged_object)
session.commit()
@classmethod
def after_update(cls, mapper, connection, target):
# pylint: disable=unused-argument
session = Session(bind=connection)
# delete current `owner:` tags
query = session.query(TaggedObject.id).join(Tag).filter(
TaggedObject.object_type == cls.object_type,
TaggedObject.object_id == target.id,
Tag.type == TagTypes.owner,
)
ids = [row[0] for row in query]
session.query(TaggedObject).filter(
TaggedObject.id.in_(ids)).delete(
synchronize_session=False)
# add `owner:` tags
cls._add_owners(session, target)
session.commit()
@classmethod
def after_delete(cls, mapper, connection, target):
# pylint: disable=unused-argument
session = Session(bind=connection)
# delete row from `tagged_objects`
session.query(TaggedObject).filter(
TaggedObject.object_type == cls.object_type,
TaggedObject.object_id == target.id,
).delete()
session.commit()
class ChartUpdater(ObjectUpdater):
object_type = 'chart'
@classmethod
def get_owners_ids(cls, target):
return [owner.id for owner in target.owners]
class DashboardUpdater(ObjectUpdater):
object_type = 'dashboard'
@classmethod
def get_owners_ids(cls, target):
return [owner.id for owner in target.owners]
class QueryUpdater(ObjectUpdater):
object_type = 'query'
@classmethod
def get_owners_ids(cls, target):
return [target.user_id]
class FavStarUpdater(object):
@classmethod
def after_insert(cls, mapper, connection, target):
# pylint: disable=unused-argument
session = Session(bind=connection)
name = 'favorited_by:{0}'.format(target.user_id)
tag = get_tag(name, session, TagTypes.favorited_by)
tagged_object = TaggedObject(
tag_id=tag.id,
object_id=target.obj_id,
object_type=get_object_type(target.class_name),
)
session.add(tagged_object)
session.commit()
@classmethod
def after_delete(cls, mapper, connection, target):
# pylint: disable=unused-argument
session = Session(bind=connection)
name = 'favorited_by:{0}'.format(target.user_id)
query = session.query(TaggedObject.id).join(Tag).filter(
TaggedObject.object_id == target.obj_id,
Tag.type == TagTypes.favorited_by,
Tag.name == name,
)
ids = [row[0] for row in query]
session.query(TaggedObject).filter(
TaggedObject.id.in_(ids)).delete(
synchronize_session=False)
session.commit()

View File

@@ -23,7 +23,10 @@ from sqlparse.tokens import Keyword, Name
RESULT_OPERATIONS = {'UNION', 'INTERSECT', 'EXCEPT', 'SELECT'}
ON_KEYWORD = 'ON'
PRECEDES_TABLE_NAME = {'FROM', 'JOIN', 'DESC', 'DESCRIBE', 'WITH'}
PRECEDES_TABLE_NAME = {
'FROM', 'JOIN', 'DESCRIBE', 'WITH', 'LEFT JOIN', 'RIGHT JOIN',
}
CTE_PREFIX = 'CTE__'
class ParsedQuery(object):
@@ -71,35 +74,23 @@ class ParsedQuery(object):
statements.append(sql)
return statements
@staticmethod
def __precedes_table_name(token_value):
for keyword in PRECEDES_TABLE_NAME:
if keyword in token_value:
return True
return False
@staticmethod
def __get_full_name(identifier):
if len(identifier.tokens) > 1 and identifier.tokens[1].value == '.':
if len(identifier.tokens) > 2 and identifier.tokens[1].value == '.':
return '{}.{}'.format(identifier.tokens[0].value,
identifier.tokens[2].value)
return identifier.get_real_name()
@staticmethod
def __is_result_operation(keyword):
for operation in RESULT_OPERATIONS:
if operation in keyword.upper():
return True
return False
@staticmethod
def __is_identifier(token):
return isinstance(token, (IdentifierList, Identifier))
def __process_identifier(self, identifier):
# exclude subselects
if '(' not in '{}'.format(identifier):
self._table_names.add(self.__get_full_name(identifier))
if '(' not in str(identifier):
table_name = self.__get_full_name(identifier)
if table_name and not table_name.startswith(CTE_PREFIX):
self._table_names.add(table_name)
return
# store aliases
@@ -129,52 +120,49 @@ class ParsedQuery(object):
exec_sql += f'CREATE TABLE {table_name} AS \n{sql}'
return exec_sql
def __extract_from_token(self, token):
def __extract_from_token(self, token, depth=0):
if not hasattr(token, 'tokens'):
return
table_name_preceding_token = False
for item in token.tokens:
logging.debug((' ' * depth) + str(item.ttype) + str(item.value))
if item.is_group and not self.__is_identifier(item):
self.__extract_from_token(item)
self.__extract_from_token(item, depth=depth + 1)
if item.ttype in Keyword:
if self.__precedes_table_name(item.value.upper()):
table_name_preceding_token = True
continue
if not table_name_preceding_token:
if (
item.ttype in Keyword and (
item.normalized in PRECEDES_TABLE_NAME or
item.normalized.endswith(' JOIN')
)):
table_name_preceding_token = True
continue
if item.ttype in Keyword or item.value == ',':
if (self.__is_result_operation(item.value) or
item.value.upper() == ON_KEYWORD):
table_name_preceding_token = False
continue
# FROM clause is over
break
if item.ttype in Keyword:
table_name_preceding_token = False
continue
if isinstance(item, Identifier):
self.__process_identifier(item)
if isinstance(item, IdentifierList):
for token in item.tokens:
if self.__is_identifier(token):
if table_name_preceding_token:
if isinstance(item, Identifier):
self.__process_identifier(item)
elif isinstance(item, IdentifierList):
for token in item.get_identifiers():
self.__process_identifier(token)
def _get_limit_from_token(self, token):
if token.ttype == sqlparse.tokens.Literal.Number.Integer:
return int(token.value)
elif token.is_group:
return int(token.get_token_at_offset(1).value)
elif isinstance(item, IdentifierList):
for token in item.tokens:
if not self.__is_identifier(token):
self.__extract_from_token(item, depth=depth + 1)
def _extract_limit_from_query(self, statement):
limit_token = None
for pos, item in enumerate(statement.tokens):
if item.ttype in Keyword and item.value.lower() == 'limit':
limit_token = statement.tokens[pos + 2]
return self._get_limit_from_token(limit_token)
idx, _ = statement.token_next_by(m=(Keyword, 'LIMIT'))
if idx is not None:
_, token = statement.token_next(idx=idx)
if token:
if isinstance(token, IdentifierList):
_, token = token.token_next(idx=-1)
if token and token.ttype == sqlparse.tokens.Literal.Number.Integer:
return int(token.value)
def get_query_with_new_limit(self, new_limit):
"""returns the query with the specified limit"""

View File

@@ -19,6 +19,7 @@
{% set menu = appbuilder.menu %}
{% set languages = appbuilder.languages %}
{% set WARNING_MSG = appbuilder.app.config.get('WARNING_MSG') %}
{% set app_icon_width = appbuilder.app.config.get('APP_ICON_WIDTH', 126) %}
<div class="navbar navbar-static-top {{menu.extra_classes}}" role="navigation">
<div class="container-fluid">
@@ -30,7 +31,7 @@
</button>
<a class="navbar-brand" href="/superset/profile/{{ current_user.username }}/">
<img
width="126"
width="{{ app_icon_width }}"
src="{{ appbuilder.app_icon }}"
alt="{{ appbuilder.app_name }}"
/>

View File

@@ -17,6 +17,7 @@
under the License.
#}
{% set bug_report_url = appbuilder.app.config.get('BUG_REPORT_URL') %}
{% set locale = session['locale'] %}
{% if not locale %}
{% set locale = 'en' %}
@@ -34,6 +35,17 @@
</ul>
</li>
{% endif %}
{% if bug_report_url %}
<li>
<a
tabindex="-1"
href="{{ bug_report_url }}"
title="Report a bug"
>
<i class="fa fa-bug"></i>&nbsp;
</a>
</li>
{% endif %}
{% if languages.keys()|length > 1 %}
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">

View File

@@ -865,10 +865,12 @@ def get_or_create_main_db():
logging.info('Creating database reference')
dbobj = get_main_database(db.session)
if not dbobj:
dbobj = models.Database(database_name='main')
dbobj = models.Database(
database_name='main',
allow_csv_upload=True,
expose_in_sqllab=True,
)
dbobj.set_sqlalchemy_uri(conf.get('SQLALCHEMY_DATABASE_URI'))
dbobj.expose_in_sqllab = True
dbobj.allow_csv_upload = True
db.session.add(dbobj)
db.session.commit()
return dbobj

View File

@@ -22,3 +22,4 @@ from . import dashboard # noqa
from . import annotations # noqa
from . import datasource # noqa
from . import schedules # noqa
from . import tags # noqa

View File

@@ -15,16 +15,18 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=R
import json
from flask import g, request
from flask import request
from flask_appbuilder import expose
from flask_appbuilder.security.decorators import has_access_api
import simplejson as json
from superset import appbuilder, security_manager
from superset import appbuilder, db, security_manager
from superset.common.query_context import QueryContext
from superset.legacy import update_time_range
import superset.models.core as models
from superset.models.core import Log
from .base import api, BaseSupersetView, data_payload_response, handle_api_exception
from superset.utils import core as utils
from .base import api, BaseSupersetView, handle_api_exception
class Api(BaseSupersetView):
@@ -37,11 +39,37 @@ class Api(BaseSupersetView):
"""
Takes a query_obj constructed in the client and returns payload data response
for the given query_obj.
params: query_context: json_blob
"""
query_context = QueryContext(**json.loads(request.form.get('query_context')))
security_manager.assert_datasource_permission(query_context.datasource, g.user)
payload_json = query_context.get_data()
return data_payload_response(payload_json)
security_manager.assert_datasource_permission(query_context.datasource)
payload_json = query_context.get_payload()
return json.dumps(
payload_json,
default=utils.json_int_dttm_ser,
ignore_nan=True,
)
@Log.log_this
@api
@handle_api_exception
@has_access_api
@expose('/v1/form_data/', methods=['GET'])
def query_form_data(self):
"""
Get the formdata stored in the database for existing slice.
params: slice_id: integer
"""
form_data = {}
slice_id = request.args.get('slice_id')
if slice_id:
slc = db.session.query(models.Slice).filter_by(id=slice_id).one_or_none()
if slc:
form_data = slc.form_data.copy()
update_time_range(form_data)
return json.dumps(form_data)
appbuilder.add_view_no_menu(Api)

View File

@@ -43,6 +43,7 @@ FRONTEND_CONF_KEYS = (
'DEFAULT_SQLLAB_LIMIT',
'SQL_MAX_ROW',
'SUPERSET_WEBSERVER_DOMAINS',
'SQLLAB_SAVE_WARNING_MESSAGE',
)

View File

@@ -378,7 +378,7 @@ class CsvToDatabaseView(SimpleFormView):
except OSError:
pass
message = 'Table name {} already exists. Please pick another'.format(
form.name.data) if isinstance(e, IntegrityError) else e
form.name.data) if isinstance(e, IntegrityError) else str(e)
flash(
message,
'danger')
@@ -610,8 +610,9 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa
}
def pre_add(self, obj):
obj.slug = obj.slug.strip() or None
obj.slug = obj.slug or None
if obj.slug:
obj.slug = obj.slug.strip()
obj.slug = obj.slug.replace(' ', '-')
obj.slug = re.sub(r'[^\w\-]+', '', obj.slug)
if g.user not in obj.owners:
@@ -1088,7 +1089,7 @@ class Superset(BaseSupersetView):
if not slc:
abort(404)
endpoint = '/superset/explore/?form_data={}'.format(
parse.quote(json.dumps(form_data)),
parse.quote(json.dumps({'slice_id': slice_id})),
)
if request.args.get('standalone') == 'true':
endpoint += '&standalone=true'
@@ -1524,12 +1525,17 @@ class Superset(BaseSupersetView):
db.session
.query(models.Database)
.filter_by(id=db_id)
.one()
.first()
)
schemas = database.all_schema_names(cache=database.schema_cache_enabled,
cache_timeout=database.schema_cache_timeout,
force=force_refresh)
schemas = security_manager.schemas_accessible_by_user(database, schemas)
if database:
schemas = database.all_schema_names(
cache=database.schema_cache_enabled,
cache_timeout=database.schema_cache_timeout,
force=force_refresh)
schemas = security_manager.schemas_accessible_by_user(database, schemas)
else:
schemas = []
return Response(
json.dumps({'schemas': schemas}),
mimetype='application/json')

View File

@@ -20,12 +20,11 @@ import json
from flask import request
from flask_appbuilder import expose
from flask_appbuilder.security.decorators import has_access_api
from flask_babel import gettext as __
from superset import appbuilder, db
from superset.connectors.connector_registry import ConnectorRegistry
from superset.models.core import Database
from .base import BaseSupersetView, check_ownership, json_error_response
from .base import BaseSupersetView
class Datasource(BaseSupersetView):
@@ -39,14 +38,6 @@ class Datasource(BaseSupersetView):
orm_datasource = ConnectorRegistry.get_datasource(
datasource_type, datasource_id, db.session)
if not check_ownership(orm_datasource, raise_if_false=False):
return json_error_response(
__(
'You are not authorized to modify '
'this data source configuration'),
status='401',
)
if 'owners' in datasource:
datasource['owners'] = db.session.query(orm_datasource.owner_class).filter(
orm_datasource.owner_class.id.in_(datasource['owners'])).all()

217
superset/views/tags.py Normal file
View File

@@ -0,0 +1,217 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=C,R,W
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.security.decorators import has_access_api
from jinja2.sandbox import SandboxedEnvironment
import simplejson as json
from sqlalchemy import and_, func
from werkzeug.routing import BaseConverter
from superset import app, appbuilder, db, utils
from superset.jinja_context import current_user_id, current_username
import superset.models.core
from superset.models.sql_lab import SavedQuery
from superset.models.tags import ObjectTypes, Tag, TaggedObject, TagTypes
from .base import BaseSupersetView, json_success
class ObjectTypeConverter(BaseConverter):
"""Validate that object_type is indeed an object type."""
def to_python(self, object_type):
return ObjectTypes[object_type]
def to_url(self, object_type):
return object_type.name
def process_template(content):
env = SandboxedEnvironment()
template = env.from_string(content)
context = {
'current_user_id': current_user_id,
'current_username': current_username,
}
return template.render(context)
def get_name(obj):
if obj.Dashboard:
return obj.Dashboard.dashboard_title
elif obj.Slice:
return obj.Slice.slice_name
elif obj.SavedQuery:
return obj.SavedQuery.label
def get_creator(obj):
if obj.Dashboard:
return obj.Dashboard.creator()
elif obj.Slice:
return obj.Slice.creator()
elif obj.SavedQuery:
return obj.SavedQuery.creator()
def get_attribute(obj, attr):
if obj.Dashboard:
return getattr(obj.Dashboard, attr)
elif obj.Slice:
return getattr(obj.Slice, attr)
elif obj.SavedQuery:
return getattr(obj.SavedQuery, attr)
class TagView(BaseSupersetView):
@has_access_api
@expose('/tags/suggestions/', methods=['GET'])
def suggestions(self):
query = (
db.session.query(TaggedObject)
.group_by(TaggedObject.tag_id)
.order_by(func.count().desc())
.all()
)
tags = [{'id': obj.tag.id, 'name': obj.tag.name} for obj in query]
return json_success(json.dumps(tags))
@has_access_api
@expose('/tags/<object_type:object_type>/<int:object_id>/', methods=['GET'])
def get(self, object_type, object_id):
"""List all tags a given object has."""
query = db.session.query(TaggedObject).filter(and_(
TaggedObject.object_type == object_type,
TaggedObject.object_id == object_id))
tags = [{'id': obj.tag.id, 'name': obj.tag.name} for obj in query]
return json_success(json.dumps(tags))
@has_access_api
@expose('/tags/<object_type:object_type>/<int:object_id>/', methods=['POST'])
def post(self, object_type, object_id):
"""Add new tags to an object."""
tagged_objects = []
for name in request.get_json(force=True):
if ':' in name:
type_name = name.split(':', 1)[0]
type_ = TagTypes[type_name]
else:
type_ = TagTypes.custom
tag = db.session.query(Tag).filter_by(name=name, type=type_).first()
if not tag:
tag = Tag(name=name, type=type_)
tagged_objects.append(
TaggedObject(
object_id=object_id,
object_type=object_type,
tag=tag,
),
)
db.session.add_all(tagged_objects)
db.session.commit()
return Response(status=201) # 201 CREATED
@has_access_api
@expose('/tags/<object_type:object_type>/<int:object_id>/', methods=['DELETE'])
def delete(self, object_type, object_id):
"""Remove tags from an object."""
tag_names = request.get_json(force=True)
if not tag_names:
return Response(status=403)
db.session.query(TaggedObject).filter(and_(
TaggedObject.object_type == object_type,
TaggedObject.object_id == object_id),
TaggedObject.tag.has(Tag.name.in_(tag_names)),
).delete(synchronize_session=False)
db.session.commit()
return Response(status=204) # 204 NO CONTENT
@has_access_api
@expose('/tagged_objects/', methods=['GET', 'POST'])
def tagged_objects(self):
query = db.session.query(
TaggedObject,
superset.models.core.Dashboard,
superset.models.core.Slice,
SavedQuery,
).join(Tag)
tags = request.args.get('tags')
if not tags:
return json_success(json.dumps([]))
tags = [process_template(tag) for tag in tags.split(',')]
query = query.filter(Tag.name.in_(tags))
# filter types
types = request.args.get('types')
if types:
query = query.filter(TaggedObject.object_type.in_(types.split(',')))
# get names
query = query.outerjoin(
superset.models.core.Dashboard,
and_(
TaggedObject.object_id == superset.models.core.Dashboard.id,
TaggedObject.object_type == ObjectTypes.dashboard,
),
).outerjoin(
superset.models.core.Slice,
and_(
TaggedObject.object_id == superset.models.core.Slice.id,
TaggedObject.object_type == ObjectTypes.chart,
),
).outerjoin(
SavedQuery,
and_(
TaggedObject.object_id == SavedQuery.id,
TaggedObject.object_type == ObjectTypes.query,
),
).group_by(TaggedObject.object_id, TaggedObject.object_type)
objects = [
{
'id': get_attribute(obj, 'id'),
'type': obj.TaggedObject.object_type.name,
'name': get_name(obj),
'url': get_attribute(obj, 'url'),
'changed_on': get_attribute(obj, 'changed_on'),
'created_by': get_attribute(obj, 'created_by_fk'),
'creator': get_creator(obj),
}
for obj in query if get_attribute(obj, 'id')
]
return json_success(json.dumps(objects, default=utils.core.json_int_dttm_ser))
app.url_map.converters['object_type'] = ObjectTypeConverter
appbuilder.add_view_no_menu(TagView)

View File

@@ -1164,10 +1164,6 @@ class NVD3TimeSeriesViz(NVD3Viz):
dfs.sort_values(ascending=False, inplace=True)
df = df[dfs.index]
if fd.get('contribution'):
dft = df.T
df = (dft / dft.sum()).T
rolling_type = fd.get('rolling_type')
rolling_periods = int(fd.get('rolling_periods') or 0)
min_periods = int(fd.get('min_periods') or 0)
@@ -1187,6 +1183,10 @@ class NVD3TimeSeriesViz(NVD3Viz):
if min_periods:
df = df[min_periods:]
if fd.get('contribution'):
dft = df.T
df = (dft / dft.sum()).T
return df
def run_extra_queries(self):
@@ -1254,7 +1254,9 @@ class NVD3TimeSeriesViz(NVD3Viz):
self.to_series(
diff, classed='time-shift-{}'.format(i), title_suffix=label))
return sorted(chart_data, key=lambda x: tuple(x['key']))
if not self.sort_series:
chart_data = sorted(chart_data, key=lambda x: tuple(x['key']))
return chart_data
class MultiLineViz(NVD3Viz):

View File

@@ -646,15 +646,14 @@ class CoreTests(SupersetTestCase):
main_db_uri = (
db.session.query(models.Database)
.filter_by(database_name='main')
.all()
.one()
)
test_file = open(filename, 'rb')
form_data = {
'csv_file': test_file,
'sep': ',',
'name': table_name,
'con': main_db_uri[0].id,
'con': main_db_uri.id,
'if_exists': 'append',
'index_label': 'test_label',
'mangle_dupe_cols': False,

View File

@@ -141,12 +141,26 @@ class DbEngineSpecsTestCase(SupersetTestCase):
q2 = 'select * from (select * from my_subquery limit 10) where col=1 limit 20'
q3 = 'select * from (select * from my_subquery limit 10);'
q4 = 'select * from (select * from my_subquery limit 10) where col=1 limit 20;'
q5 = 'select * from mytable limit 10, 20'
q6 = 'select * from mytable limit 10 offset 20'
q7 = 'select * from mytable limit'
q8 = 'select * from mytable limit 10.0'
q9 = 'select * from mytable limit x'
q10 = 'select * from mytable limit x, 20'
q11 = 'select * from mytable limit x offset 20'
self.assertEqual(engine_spec_class.get_limit_from_sql(q0), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q1), 10)
self.assertEqual(engine_spec_class.get_limit_from_sql(q2), 20)
self.assertEqual(engine_spec_class.get_limit_from_sql(q3), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q4), 20)
self.assertEqual(engine_spec_class.get_limit_from_sql(q5), 10)
self.assertEqual(engine_spec_class.get_limit_from_sql(q6), 10)
self.assertEqual(engine_spec_class.get_limit_from_sql(q7), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q8), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q9), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q10), None)
self.assertEqual(engine_spec_class.get_limit_from_sql(q11), None)
def test_wrapped_query(self):
self.sql_limit_regex(

View File

@@ -47,6 +47,11 @@ class SupersetTestCase(unittest.TestCase):
{'schemaname.tbname'},
self.extract_tables('SELECT * FROM schemaname.tbname'))
# Ill-defined schema/table.
self.assertEquals(
set(),
self.extract_tables('SELECT * FROM schemaname.'))
# quotes
query = 'SELECT field1, field2 FROM tb_name'
self.assertEquals({'tb_name'}, self.extract_tables(query))
@@ -167,7 +172,6 @@ class SupersetTestCase(unittest.TestCase):
# DESCRIBE | DESC qualifiedName
def test_describe(self):
self.assertEquals({'t1'}, self.extract_tables('DESCRIBE t1'))
self.assertEquals({'t1'}, self.extract_tables('DESC t1'))
# SHOW PARTITIONS FROM qualifiedName (WHERE booleanExpression)?
# (ORDER BY sortItem (',' sortItem)*)? (LIMIT limit=(INTEGER_VALUE | ALL))?
@@ -349,6 +353,32 @@ class SupersetTestCase(unittest.TestCase):
{'table_a', 'table_b', 'table_c'},
self.extract_tables(query))
def test_mixed_from_clause(self):
query = """SELECT *
FROM table_a AS a, (select * from table_b) AS b, table_c as c
WHERE a.id = b.id and b.id = c.id"""
self.assertEquals(
{'table_a', 'table_b', 'table_c'},
self.extract_tables(query))
def test_nested_selects(self):
query = """
select (extractvalue(1,concat(0x7e,(select GROUP_CONCAT(TABLE_NAME)
from INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA like "%bi%"),0x7e)));
"""
self.assertEquals(
{'INFORMATION_SCHEMA.COLUMNS'},
self.extract_tables(query))
query = """
select (extractvalue(1,concat(0x7e,(select GROUP_CONCAT(COLUMN_NAME)
from INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME="bi_achivement_daily"),0x7e)));
"""
self.assertEquals(
{'INFORMATION_SCHEMA.COLUMNS'},
self.extract_tables(query))
def test_complex_extract_tables3(self):
query = """SELECT somecol AS somecol
FROM
@@ -386,6 +416,21 @@ class SupersetTestCase(unittest.TestCase):
{'a', 'b', 'c', 'd', 'e', 'f'},
self.extract_tables(query))
def test_complex_cte_with_prefix(self):
query = """
WITH CTE__test (SalesPersonID, SalesOrderID, SalesYear)
AS (
SELECT SalesPersonID, SalesOrderID, YEAR(OrderDate) AS SalesYear
FROM SalesOrderHeader
WHERE SalesPersonID IS NOT NULL
)
SELECT SalesPersonID, COUNT(SalesOrderID) AS TotalSales, SalesYear
FROM CTE__test
GROUP BY SalesYear, SalesPersonID
ORDER BY SalesPersonID, SalesYear;
"""
self.assertEquals({'SalesOrderHeader'}, self.extract_tables(query))
def test_basic_breakdown_statements(self):
multi_sql = """
SELECT * FROM ab_user;