Compare commits
312 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b88fd4d2 | ||
|
|
2e172d77cf | ||
|
|
8fe1f8fb3f | ||
|
|
c4eba9e467 | ||
|
|
90d9616f2b | ||
|
|
803738436e | ||
|
|
f14c1bb593 | ||
|
|
31a0b6e5b0 | ||
|
|
27538386bc | ||
|
|
3b35ddf135 | ||
|
|
d5ab6c8d3d | ||
|
|
a4ecff4e23 | ||
|
|
19a0827d1f | ||
|
|
2d8a0cc6c9 | ||
|
|
2789385688 | ||
|
|
e965f95477 | ||
|
|
ad212272d1 | ||
|
|
6d37d97ba5 | ||
|
|
fdd42ef4b6 | ||
|
|
a616bf4082 | ||
|
|
a9e1e685ba | ||
|
|
d41418eaa0 | ||
|
|
1f8fccc0f9 | ||
|
|
75a2b4f610 | ||
|
|
133f98ad58 | ||
|
|
1a7ef4758b | ||
|
|
c77bab8160 | ||
|
|
724c3f48a4 | ||
|
|
073d56cb33 | ||
|
|
e4a95f9428 | ||
|
|
1b06140bde | ||
|
|
f8dcbf70c5 | ||
|
|
b9299d61ac | ||
|
|
2384ad4eb5 | ||
|
|
2b66eadee2 | ||
|
|
94d9337e0b | ||
|
|
a0621e10a8 | ||
|
|
b72d5b03dc | ||
|
|
914480ad3c | ||
|
|
ff2f85f39b | ||
|
|
9cf16a4ff2 | ||
|
|
b90c410c01 | ||
|
|
77d1e5d046 | ||
|
|
4bc5fe5495 | ||
|
|
2c72a7ae4f | ||
|
|
4b11f45f72 | ||
|
|
04ae004f43 | ||
|
|
29ef8c4af8 | ||
|
|
718230cdf2 | ||
|
|
8175e19f72 | ||
|
|
7b76356182 | ||
|
|
1c56319be4 | ||
|
|
36caca3244 | ||
|
|
5079b2aa95 | ||
|
|
cab8e7d22d | ||
|
|
85d137b20a | ||
|
|
a942f81dfd | ||
|
|
01043c9bf4 | ||
|
|
a9610e2886 | ||
|
|
5897d85f7a | ||
|
|
0367dce38b | ||
|
|
1ca1395382 | ||
|
|
2607e4be4d | ||
|
|
04680e5ff1 | ||
|
|
a7a6678d5c | ||
|
|
8069d6221d | ||
|
|
269f55c29a | ||
|
|
bca27b436b | ||
|
|
aecaa85905 | ||
|
|
7e36488f03 | ||
|
|
87c3e831a8 | ||
|
|
ee63ebc8ec | ||
|
|
5916291901 | ||
|
|
4b0f252170 | ||
|
|
9176a4072b | ||
|
|
0cb7c5e4a6 | ||
|
|
e182f7f962 | ||
|
|
23c98294bd | ||
|
|
22bdd9e324 | ||
|
|
b159e51787 | ||
|
|
d57012067b | ||
|
|
9364fb5b79 | ||
|
|
c49fb0aa9b | ||
|
|
b9af019567 | ||
|
|
e7f8143c3b | ||
|
|
c9e47f0bb3 | ||
|
|
686023c8dd | ||
|
|
d997a450cf | ||
|
|
9e053923d4 | ||
|
|
ef06a9d497 | ||
|
|
37205099db | ||
|
|
e498f2fcb6 | ||
|
|
f7c55270db | ||
|
|
0a6208296e | ||
|
|
bf4d3a0dff | ||
|
|
b227612f6e | ||
|
|
45686a1af6 | ||
|
|
82ed4878c4 | ||
|
|
6e1ec8347d | ||
|
|
f905726c24 | ||
|
|
69195f8d2d | ||
|
|
b4909f2d03 | ||
|
|
44e753d94d | ||
|
|
e4903e6dc6 | ||
|
|
d4e8d57fc4 | ||
|
|
281ae45495 | ||
|
|
ff4f9b4527 | ||
|
|
86f9087ea2 | ||
|
|
7cd9b85831 | ||
|
|
71e1eea9f4 | ||
|
|
1e79e9cd2a | ||
|
|
af7cdeba4d | ||
|
|
500e6256c0 | ||
|
|
e79d05fd77 | ||
|
|
fc85756c20 | ||
|
|
6081f7161a | ||
|
|
c21513fb8c | ||
|
|
ec752b1378 | ||
|
|
cf1d9ce1e6 | ||
|
|
6188d60fec | ||
|
|
dfc28f37eb | ||
|
|
23c834f04e | ||
|
|
c84211ec44 | ||
|
|
7d374428d3 | ||
|
|
3a2974f589 | ||
|
|
3ed8f5fc23 | ||
|
|
61755f0b7d | ||
|
|
0a3d2fccd4 | ||
|
|
0b40c8a26f | ||
|
|
81df7087db | ||
|
|
cb7c5aa70c | ||
|
|
5bc581fd44 | ||
|
|
5ee70b244b | ||
|
|
a26cf001c4 | ||
|
|
e02d35ed5c | ||
|
|
e98a1c3537 | ||
|
|
4404751a1d | ||
|
|
defe6789c0 | ||
|
|
823f306f24 | ||
|
|
72627b1761 | ||
|
|
1702b020be | ||
|
|
89f6ccc1c6 | ||
|
|
eff5952641 | ||
|
|
f10395b2f7 | ||
|
|
b2647567c0 | ||
|
|
028456572b | ||
|
|
84a7730f47 | ||
|
|
76a2f95231 | ||
|
|
9904593dc3 | ||
|
|
8f00e9e30b | ||
|
|
16ab696d7c | ||
|
|
1ce14df43d | ||
|
|
34d6618b2e | ||
|
|
abdd1d537f | ||
|
|
d9fda346cb | ||
|
|
6cbe0e6096 | ||
|
|
268edcfedd | ||
|
|
c5ddf57124 | ||
|
|
f9202ba179 | ||
|
|
17635e1a2b | ||
|
|
285197926e | ||
|
|
5466fab2a0 | ||
|
|
ed85032277 | ||
|
|
680e1cbb42 | ||
|
|
2d37dec5ff | ||
|
|
3f4c306bd6 | ||
|
|
ac432495d7 | ||
|
|
12fb7c1a62 | ||
|
|
feb15a30a2 | ||
|
|
eb0f3970cf | ||
|
|
b82d15af76 | ||
|
|
32b38ee2d6 | ||
|
|
1d702f2142 | ||
|
|
4ae77ba8af | ||
|
|
3c72e1f8fb | ||
|
|
4bfe08d7c3 | ||
|
|
3a7ed8d194 | ||
|
|
4d204b3b36 | ||
|
|
39ee33aeff | ||
|
|
831cd21737 | ||
|
|
a82bb588f4 | ||
|
|
a84bd5225c | ||
|
|
f0acc11249 | ||
|
|
fa35d7d2f4 | ||
|
|
e65aba3c46 | ||
|
|
fab7b1083b | ||
|
|
d9161fb76a | ||
|
|
85b18ff5e7 | ||
|
|
3a8af5d0b0 | ||
|
|
1c545d3a2d | ||
|
|
120a5d08f9 | ||
|
|
b586cb0ba7 | ||
|
|
ae2205aeb5 | ||
|
|
2e25fc4161 | ||
|
|
ba89b2d091 | ||
|
|
aee8438924 | ||
|
|
a6ba841e57 | ||
|
|
8643228b51 | ||
|
|
de869973c7 | ||
|
|
ac57780607 | ||
|
|
630604bc6b | ||
|
|
eb5d220b5e | ||
|
|
3f076b00cd | ||
|
|
514f9452f3 | ||
|
|
068c343be0 | ||
|
|
500455fc72 | ||
|
|
1b4f128f55 | ||
|
|
1a3a8daf49 | ||
|
|
7fce8eab3a | ||
|
|
b4c9402737 | ||
|
|
8459347bdc | ||
|
|
f7bf17290c | ||
|
|
d908e48d61 | ||
|
|
a3a4687ebf | ||
|
|
4d48d5d854 | ||
|
|
83e6807fa0 | ||
|
|
ba96984048 | ||
|
|
591e5ec32e | ||
|
|
690de862e8 | ||
|
|
35810ce2bf | ||
|
|
6c52f2ff72 | ||
|
|
d663bea5e6 | ||
|
|
1ea4521d0c | ||
|
|
c4153c0bbe | ||
|
|
ae8b249dc2 | ||
|
|
9500f0aae3 | ||
|
|
be3da6396f | ||
|
|
330926c167 | ||
|
|
cbcc00c929 | ||
|
|
d03b74f754 | ||
|
|
ec21d5af21 | ||
|
|
70c7315ae0 | ||
|
|
4fa1f0ab17 | ||
|
|
39e502faae | ||
|
|
0280bc52e0 | ||
|
|
dee47864c4 | ||
|
|
17623f71d4 | ||
|
|
7453131858 | ||
|
|
e822fb50d8 | ||
|
|
e2bca47421 | ||
|
|
7987cb794b | ||
|
|
7483e2c942 | ||
|
|
e6129eb492 | ||
|
|
b10aca2de1 | ||
|
|
02cbad59de | ||
|
|
ccb87d337c | ||
|
|
63a49983eb | ||
|
|
81dd622fdb | ||
|
|
9a49b1c41d | ||
|
|
b059506afa | ||
|
|
13c17e1526 | ||
|
|
8e3217a921 | ||
|
|
aed7c7436a | ||
|
|
7f3edad119 | ||
|
|
7fd9c82ae8 | ||
|
|
f3c7052f30 | ||
|
|
326d90a5e4 | ||
|
|
cccc47311b | ||
|
|
5c03167948 | ||
|
|
87b6d76c32 | ||
|
|
abfa03474c | ||
|
|
5bc734b2e5 | ||
|
|
814b70ffd8 | ||
|
|
1e18bfdea4 | ||
|
|
200b66d088 | ||
|
|
cbd01074ba | ||
|
|
1582fa1964 | ||
|
|
a9b6d11ade | ||
|
|
c4b6324e74 | ||
|
|
9432ea80be | ||
|
|
f412b4c158 | ||
|
|
547a3bf4e7 | ||
|
|
e97dc9d3cb | ||
|
|
efae14592e | ||
|
|
1d06495629 | ||
|
|
ffdfdb94ab | ||
|
|
8d7e97a26e | ||
|
|
f8b8f6a343 | ||
|
|
4967342362 | ||
|
|
9893847991 | ||
|
|
18e9640d99 | ||
|
|
58ea736ed6 | ||
|
|
b4bdc45a6b | ||
|
|
fa07b8d51b | ||
|
|
e121a8585e | ||
|
|
adef519583 | ||
|
|
08f09b4761 | ||
|
|
2a89c90e0b | ||
|
|
ce5fa379ec | ||
|
|
d4d4a9b1f1 | ||
|
|
d0b5b449b2 | ||
|
|
bad6938d1a | ||
|
|
48e28eff9b | ||
|
|
f87163413b | ||
|
|
52a9f2742b | ||
|
|
7f07fbefbc | ||
|
|
93660c6838 | ||
|
|
3ebadbcda9 | ||
|
|
3df3e0d681 | ||
|
|
4a3c09187a | ||
|
|
76f8d33d81 | ||
|
|
6cc6637454 | ||
|
|
d7f8a7fde3 | ||
|
|
80eb9c2c64 | ||
|
|
bd45e3b19a | ||
|
|
b866b33dee | ||
|
|
8994bdacbd | ||
|
|
f3b403d346 | ||
|
|
5ad4167512 | ||
|
|
ca67a7a4e9 | ||
|
|
64ef8b14b4 | ||
|
|
912c6f6231 |
2
.gitignore
vendored
@@ -31,7 +31,7 @@ app.db
|
||||
*.entry.js
|
||||
*.js.map
|
||||
node_modules
|
||||
npm-debug.log
|
||||
npm-debug.log*
|
||||
yarn.lock
|
||||
superset/assets/version_info.json
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[pycodestyle]
|
||||
max-line-length = 90
|
||||
@@ -102,7 +102,7 @@ evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / stateme
|
||||
good-names=i,j,k,ex,Run,_,d,e,v,o,l,x,ts
|
||||
|
||||
# Bad variable names which should always be refused, separated by a comma
|
||||
bad-names=foo,bar,baz,toto,tutu,tata
|
||||
bad-names=foo,bar,baz,toto,tutu,tata,d,fd
|
||||
|
||||
# Colon-delimited sets of names that determine each other's naming style when
|
||||
# the name regexes allow several styles.
|
||||
|
||||
11
.travis.yml
@@ -10,25 +10,22 @@ cache:
|
||||
env:
|
||||
global:
|
||||
- TRAVIS_CACHE=$HOME/.travis_cache/
|
||||
- TRAVIS_NODE_VERSION="7.10.0"
|
||||
matrix:
|
||||
- TOX_ENV=flake8
|
||||
- TOX_ENV=javascript
|
||||
- TOX_ENV=pylint
|
||||
- TOX_ENV=py34-postgres
|
||||
- TOX_ENV=py34-sqlite
|
||||
- TOX_ENV=py27-mysql
|
||||
- TOX_ENV=py27-sqlite
|
||||
before_install:
|
||||
- npm install -g npm@'>=5.4.1'
|
||||
before_script:
|
||||
- mysql -e 'drop database if exists superset; create database superset DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci' -u root
|
||||
- mysql -u root -e "DROP DATABASE IF EXISTS superset; CREATE DATABASE superset DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci"
|
||||
- mysql -u root -e "CREATE USER 'mysqluser'@'localhost' IDENTIFIED BY 'mysqluserpassword';"
|
||||
- mysql -u root -e "GRANT ALL ON superset.* TO 'mysqluser'@'localhost';"
|
||||
- psql -c 'create database superset;' -U postgres
|
||||
- psql -c "CREATE USER postgresuser WITH PASSWORD 'pguserpassword';" -U postgres
|
||||
- psql -U postgres -c "CREATE DATABASE superset;"
|
||||
- psql -U postgres -c "CREATE USER postgresuser WITH PASSWORD 'pguserpassword';"
|
||||
- export PATH=${PATH}:/tmp/hive/bin
|
||||
install:
|
||||
- pip install --upgrade pip
|
||||
- pip install tox tox-travis
|
||||
- rm -rf ~/.nvm && git clone https://github.com/creationix/nvm.git ~/.nvm && (cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`) && source ~/.nvm/nvm.sh && nvm install $TRAVIS_NODE_VERSION
|
||||
script: tox -e $TOX_ENV
|
||||
|
||||
245
CHANGELOG.md
@@ -1,5 +1,250 @@
|
||||
## Change Log
|
||||
|
||||
### 0.22.1
|
||||
Fixes 0.22.0
|
||||
|
||||
### 0.22.0
|
||||
Bad empty release
|
||||
|
||||
### 0.21.2 (2017/12/11 21:18 +00:00)
|
||||
- [#3974](https://github.com/apache/incubator-superset/pull/3974) [Bugfix] `_add_filters_from_pre_query` doesn't handle dim specs (#3974) (@Mogball)
|
||||
- [#4041](https://github.com/apache/incubator-superset/pull/4041) [API] Deprecate /update_role/ API endpoint (#4041) (@john-bodley)
|
||||
- [#4030](https://github.com/apache/incubator-superset/pull/4030) resolve python2 str() issue (#4030) (@timifasubaa)
|
||||
|
||||
### 0.21.1
|
||||
Bad-empty release
|
||||
|
||||
### 0.21.0 (2017/12/08 09:11 +00:00)
|
||||
- [#4031](https://github.com/apache/incubator-superset/pull/4031) apply custom css for dashboard initially load (#4031) (@graceguo-supercat)
|
||||
- [#3891](https://github.com/apache/incubator-superset/pull/3891) [BUGFIX]: Fixing dttm_sql_literal to use python_date_format when specified. (#3891) (@fabianmenges)
|
||||
- [#3947](https://github.com/apache/incubator-superset/pull/3947) Add fastdom js dependency (#3947) (@mistercrunch)
|
||||
- [#4015](https://github.com/apache/incubator-superset/pull/4015) Remove unused callbacks when setting state (#4015) (@betodealmeida)
|
||||
- [#4017](https://github.com/apache/incubator-superset/pull/4017) Fixed finding postaggregations (#4017) (@Mogball)
|
||||
- [#3941](https://github.com/apache/incubator-superset/pull/3941) New time_pivot visualization (#3941) (@mistercrunch)
|
||||
- [#3959](https://github.com/apache/incubator-superset/pull/3959) Add type MONEY as numeric type (#3959) (@mistercrunch)
|
||||
- [#3969](https://github.com/apache/incubator-superset/pull/3969) Add row_limit to heatmap controls (#3969) (@mistercrunch)
|
||||
- [#4019](https://github.com/apache/incubator-superset/pull/4019) Add support of another DatabaseError format (#4019) (@dmigo)
|
||||
- [#3975](https://github.com/apache/incubator-superset/pull/3975) asciifying http header for csv download; fixes #3952 (#3975) (@rumbin)
|
||||
- [#4001](https://github.com/apache/incubator-superset/pull/4001) Add has_access to import_dashboard (#4001) (@timifasubaa)
|
||||
- [#3999](https://github.com/apache/incubator-superset/pull/3999) [sql lab] fix position of 'save query' Popover (#3999) (@mistercrunch)
|
||||
- [#4003](https://github.com/apache/incubator-superset/pull/4003) Call props.onChange only when closing filter (#4003) (@betodealmeida)
|
||||
- [#3978](https://github.com/apache/incubator-superset/pull/3978) Adding YAML Import-Export for Datasources to CLI (#3978) (@fabianmenges)
|
||||
- [#4009](https://github.com/apache/incubator-superset/pull/4009) Rollback bulk-delete of table columns (#4009) (@alanmcruickshank)
|
||||
- [#4000](https://github.com/apache/incubator-superset/pull/4000) Add Datasource Name filter under slice list view (#4000) (@graceguo-supercat)
|
||||
- [#3997](https://github.com/apache/incubator-superset/pull/3997) Alternate PR to #3970 (#3997) (@mistercrunch)
|
||||
- [#3976](https://github.com/apache/incubator-superset/pull/3976) [doc] added setting X-Forwarded-Proto to https behind reverse proxy with ssl encryption; fixes #3655 (#3976) (@rumbin)
|
||||
- [#3991](https://github.com/apache/incubator-superset/pull/3991) Create CODE_OF_CONDUCT.md (#3991) (@mistercrunch)
|
||||
- [#3967](https://github.com/apache/incubator-superset/pull/3967) [Dashboard] fix a filter refresh bug and add Test (#3967) (@graceguo-supercat)
|
||||
- [#3965](https://github.com/apache/incubator-superset/pull/3965) [docs] making it clear sqlite shouldn't be used in a cluster (#3965) (@mistercrunch)
|
||||
- [#3957](https://github.com/apache/incubator-superset/pull/3957) [time series table] visual improvements (#3957) (@williaster)
|
||||
- [#3958](https://github.com/apache/incubator-superset/pull/3958) Improving speed of dashboard import (#3958) (@michellethomas)
|
||||
- [#3949](https://github.com/apache/incubator-superset/pull/3949) [Bugfix] Druid `run_query` dimensions part 3 + Unit tests (#3949) (@Mogball)
|
||||
- [#3946](https://github.com/apache/incubator-superset/pull/3946) [country_map] use Albers USA projection (#3946) (@mistercrunch)
|
||||
- [#3948](https://github.com/apache/incubator-superset/pull/3948) fix 'superset db history' (#3948) (@mistercrunch)
|
||||
- [#3951](https://github.com/apache/incubator-superset/pull/3951) Allow underscores in slugs (#3951) (@michellethomas)
|
||||
- [#3955](https://github.com/apache/incubator-superset/pull/3955) config: bring back sqlite default database (#3955) (@xrmx)
|
||||
- [#3940](https://github.com/apache/incubator-superset/pull/3940) Add an "Edit Mode" to Dashboard view (#3940) (@mistercrunch)
|
||||
- [#3920](https://github.com/apache/incubator-superset/pull/3920) Fixed branching condition with dimension spec (#3920) (@Mogball)
|
||||
- [#3643](https://github.com/apache/incubator-superset/pull/3643) Import CSV (#3643) (@timifasubaa)
|
||||
- [#3945](https://github.com/apache/incubator-superset/pull/3945) Fix call in Chart (#3945) (@mistercrunch)
|
||||
- [#3933](https://github.com/apache/incubator-superset/pull/3933) minor filter select enhancements (#3933) (@kkalyan)
|
||||
- [#3929](https://github.com/apache/incubator-superset/pull/3929) Make Table Columns & Metrics Bulk-deletable (#3929) (@alanmcruickshank)
|
||||
- [#3922](https://github.com/apache/incubator-superset/pull/3922) [travis] Standardizing before_install (#3922) (@john-bodley)
|
||||
- [#3923](https://github.com/apache/incubator-superset/pull/3923) Switched to span instead of textarea for copytoclipboard (#3923) (@Mogball)
|
||||
- [#3924](https://github.com/apache/incubator-superset/pull/3924) Moved percent metrics to its own row (#3924) (@Mogball)
|
||||
- [#3875](https://github.com/apache/incubator-superset/pull/3875) Revert "Filter out unavailable databases (#3875)" (#3918) (@mistercrunch)
|
||||
- [#3913](https://github.com/apache/incubator-superset/pull/3913) [bugfix] remove quotes from Postgres time grains (#3913) (@mistercrunch)
|
||||
- [#3915](https://github.com/apache/incubator-superset/pull/3915) Fix left padding in dashboard widgets (#3915) (@mistercrunch)
|
||||
- [#3916](https://github.com/apache/incubator-superset/pull/3916) [cosmetic] remove border from table viz (#3916) (@mistercrunch)
|
||||
- [#3912](https://github.com/apache/incubator-superset/pull/3912) When checking if you should renderTriggered make sure key exists in controls (#3912) (@michellethomas)
|
||||
- [#3906](https://github.com/apache/incubator-superset/pull/3906) fix the schema-fetching problem for impala in sql_lab (#3906) (@xiaoyugit)
|
||||
- [#3911](https://github.com/apache/incubator-superset/pull/3911) Add UK Metropolitan Districts and Isle of Man (#3911) (@alanmcruickshank)
|
||||
- [#3904](https://github.com/apache/incubator-superset/pull/3904) Bumping webpack related deps (#3904) (@mistercrunch)
|
||||
- [#3902](https://github.com/apache/incubator-superset/pull/3902) [bugfix] allow limiting word cloud (#3902) (@mistercrunch)
|
||||
|
||||
### 0.21.0rc2 (2017/11/20 17:18 +00:00)
|
||||
- [#3903](https://github.com/apache/incubator-superset/pull/3903) Fixes default hanlding in Altered slice tag (#3903) (@mistercrunch)
|
||||
- [#3910](https://github.com/apache/incubator-superset/pull/3910) Workaround pandas bug in datetimes with time zones (#3910) (@bolkedebruin)
|
||||
- [#3583](https://github.com/apache/incubator-superset/pull/3583) [3541] Augmenting datasources uniqueness constraints (#3583) (@john-bodley)
|
||||
- [#3895](https://github.com/apache/incubator-superset/pull/3895) [druid] Fixing issue 3894 multi-processing w/ Gunicorn (#3895) (@john-bodley)
|
||||
- [#3897](https://github.com/apache/incubator-superset/pull/3897) [druid] Catch IOError when fetching Druid datasource time boundary (#3897) (@john-bodley)
|
||||
- [#3899](https://github.com/apache/incubator-superset/pull/3899) [druid] Renaming refresh_async method (#3899) (@john-bodley)
|
||||
- [#3884](https://github.com/apache/incubator-superset/pull/3884) Add datasource to the SliceAddView modal (#3884) (#3900) (@alanmcruickshank)
|
||||
- [#3890](https://github.com/apache/incubator-superset/pull/3890) [dashboard bug]Instant control should take effect instantly (#3890) (@graceguo-supercat)
|
||||
- [#3879](https://github.com/apache/incubator-superset/pull/3879) Allow users to specify label->color mapping (#3879) (@mistercrunch)
|
||||
- [#3893](https://github.com/apache/incubator-superset/pull/3893) Only refreshing non instant filters on apply (#3893) (@michellethomas)
|
||||
|
||||
### 0.21.0rc1 (2017/11/17 17:33 +00:00)
|
||||
- [#3896](https://github.com/apache/incubator-superset/pull/3896) [druid] Fix datasource column enumeration (#3896) (@john-bodley)
|
||||
- [#3852](https://github.com/apache/incubator-superset/pull/3852) fix input height to match with react-select (#3852) (@graceguo-supercat)
|
||||
- [#3887](https://github.com/apache/incubator-superset/pull/3887) Fixing the build's linting errors (#3887) (@mistercrunch)
|
||||
- [#3851](https://github.com/apache/incubator-superset/pull/3851) A better looking favicon (#3851) (@mistercrunch)
|
||||
- [#3876](https://github.com/apache/incubator-superset/pull/3876) Fix slug function (#3876) (@mistercrunch)
|
||||
- [#3880](https://github.com/apache/incubator-superset/pull/3880) [table] show 'Time' column header instead of '__timestamp' (#3880) (@mistercrunch)
|
||||
- [#3771](https://github.com/apache/incubator-superset/pull/3771) DECKGL integration - Phase 1 (#3771) (@mistercrunch)
|
||||
- [#3843](https://github.com/apache/incubator-superset/pull/3843) Further refactoring around dashboards (#3843) (@mistercrunch)
|
||||
- [#3877](https://github.com/apache/incubator-superset/pull/3877) [dashboard bug] Fix standalone slice (#3877) (@graceguo-supercat)
|
||||
- [#3872](https://github.com/apache/incubator-superset/pull/3872) Add mailing list and move screenshot at the end of README (#3872) (@xrmx)
|
||||
- [#3875](https://github.com/apache/incubator-superset/pull/3875) Filter out unavailable databases (#3875) (@dmigo)
|
||||
|
||||
### 0.20.6 (2017/11/15 05:26 +00:00)
|
||||
- [#3865](https://github.com/apache/incubator-superset/pull/3865) [issue] Resolving issue 2530 (#3865) (@john-bodley)
|
||||
- [#3809](https://github.com/apache/incubator-superset/pull/3809) [cache] Fixing cache key w/ merged extra filters (#3809) (@john-bodley)
|
||||
- [#3869](https://github.com/apache/incubator-superset/pull/3869) Fixing an issue with stripping filter values (#3869) (@michellethomas)
|
||||
- [#3862](https://github.com/apache/incubator-superset/pull/3862) [flake8] Updaing CONTRIBUTING.md (#3862) (@john-bodley)
|
||||
- [#3866](https://github.com/apache/incubator-superset/pull/3866) [Dashboard bug] Fix merged filter param name (#3866) (@graceguo-supercat)
|
||||
- [#3858](https://github.com/apache/incubator-superset/pull/3858) Fix cachedDttm prop type (#3858) (@graceguo-supercat)
|
||||
- [#3847](https://github.com/apache/incubator-superset/pull/3847) [flake8] Resolving Q??? errors (#3847) (@john-bodley)
|
||||
- [#3856](https://github.com/apache/incubator-superset/pull/3856) adding support for getting list of foreign tables for PostgreSQL (#3856) (@mike-schiller)
|
||||
- [#3834](https://github.com/apache/incubator-superset/pull/3834) [Dashboard bug] Slice doesn't show loading icon when loading (#3834) (@graceguo-supercat)
|
||||
- [#3857](https://github.com/apache/incubator-superset/pull/3857) [Dashboard bug]Fix userId prop in Explore view Save_Modal (#3857) (@graceguo-supercat)
|
||||
- [#3850](https://github.com/apache/incubator-superset/pull/3850) [sql lab] minor cosmetic touchups on Run / Save buttons (#3850) (@mistercrunch)
|
||||
- [#3849](https://github.com/apache/incubator-superset/pull/3849) [sqllab] fix wrong error msg (#3849) (@mistercrunch)
|
||||
- [#3842](https://github.com/apache/incubator-superset/pull/3842) Add CHANGELOG.md entries for 0.20.0 to 0.20.5 (#3842) (@mistercrunch)
|
||||
- [#3846](https://github.com/apache/incubator-superset/pull/3846) [flake8] Resolving F5?? errors (#3846) (@john-bodley)
|
||||
- [#3841](https://github.com/apache/incubator-superset/pull/3841) [Dashboard bug] should reset chartAlert when start new query (#3841) (@graceguo-supercat)
|
||||
- [#3510](https://github.com/apache/incubator-superset/pull/3510) Update setup.py (#3510) (@joriewong)
|
||||
- [#3833](https://github.com/apache/incubator-superset/pull/3833) [Dashboard bug] Fix Cache status and dttm information display for each slice (#3833) (@graceguo-supercat)
|
||||
- [#3837](https://github.com/apache/incubator-superset/pull/3837) [Dashboard bug] should reset chartAlert when start new query (#3837) (@graceguo-supercat)
|
||||
- [#3836](https://github.com/apache/incubator-superset/pull/3836) run_tests.sh: call coveralls only on CI (#3836) (@xrmx)
|
||||
- [#3838](https://github.com/apache/incubator-superset/pull/3838) [slice] Removing deprecated argument (#3838) (@john-bodley)
|
||||
- [#3839](https://github.com/apache/incubator-superset/pull/3839) [viz] Fix payload force logic (#3839) (@john-bodley)
|
||||
- [#3668](https://github.com/apache/incubator-superset/pull/3668) [Explore] Altered Slice Tag (#3668) (@Mogball)
|
||||
- [#3813](https://github.com/apache/incubator-superset/pull/3813) [docs] add StatsD setup instructions (#3813) (@mistercrunch)
|
||||
- [#3814](https://github.com/apache/incubator-superset/pull/3814) [flake8] Resolving E3?? errors (#3814) (@john-bodley)
|
||||
- [#3831](https://github.com/apache/incubator-superset/pull/3831) Bump celery to 4.1.0 (#3831) (@mistercrunch)
|
||||
- [#3805](https://github.com/apache/incubator-superset/pull/3805) [flake8] Resolve E1?? errors (#3805) (@john-bodley)
|
||||
- [#3815](https://github.com/apache/incubator-superset/pull/3815) [docstring] Refining warm_up_cache comment (#3815) (@john-bodley)
|
||||
- [#3822](https://github.com/apache/incubator-superset/pull/3822) First time fetching chart should not force refresh. (#3822) (@graceguo-supercat)
|
||||
- [#3740](https://github.com/apache/incubator-superset/pull/3740) Basic German Translation (#3740) (@alanmcruickshank)
|
||||
- [#3816](https://github.com/apache/incubator-superset/pull/3816) [flake8] Resolving E7?? errors (#3816) (@john-bodley)
|
||||
- [#3817](https://github.com/apache/incubator-superset/pull/3817) [flake8] Resolving E4?? errors (#3817) (@john-bodley)
|
||||
- [#3819](https://github.com/apache/incubator-superset/pull/3819) Added /healthcheck endpoint for integrations with envoy (#3819) (@hughhhh)
|
||||
- [#3818](https://github.com/apache/incubator-superset/pull/3818) Fix typo in installation.rst (#3818) (@pswaminathan)
|
||||
- [#3825](https://github.com/apache/incubator-superset/pull/3825) Fix misleading SQL Lab timeout error message (#3825) (@mistercrunch)
|
||||
- [#3823](https://github.com/apache/incubator-superset/pull/3823) fix error message format when long query timeout (#3823) (@graceguo-supercat)
|
||||
- [#3810](https://github.com/apache/incubator-superset/pull/3810) Make overflow important to allow scrolling on dashboard (#3810) (@michellethomas)
|
||||
- [#3811](https://github.com/apache/incubator-superset/pull/3811) [flake8] Resolving F4?? errors (#3811) (@john-bodley)
|
||||
- [#3812](https://github.com/apache/incubator-superset/pull/3812) [flake8] Resolving E2?? errors (#3812) (@john-bodley)
|
||||
- [#3808](https://github.com/apache/incubator-superset/pull/3808) Making time table viz scrollable (#3808) (@michellethomas)
|
||||
- [#3581](https://github.com/apache/incubator-superset/pull/3581) Dashboard refactory (#3581) (@graceguo-supercat)
|
||||
- [#3801](https://github.com/apache/incubator-superset/pull/3801) Stamping version to 0.21.0dev (#3801) (@mistercrunch)
|
||||
- [#3433](https://github.com/apache/incubator-superset/pull/3433) Allowing Leading and Trailing spaces in connection (#3433) (@ishpreet-singh)
|
||||
- [#3796](https://github.com/apache/incubator-superset/pull/3796) Fixed single extraction dimension error (#3796) (@Mogball)
|
||||
- [#3787](https://github.com/apache/incubator-superset/pull/3787) [flake8] Resolving C??? errors (#3787) (@john-bodley)
|
||||
- [#3716](https://github.com/apache/incubator-superset/pull/3716) Update messages.json (#3716) (@magicansk)
|
||||
- [#3784](https://github.com/apache/incubator-superset/pull/3784) [flake8] Resolving W??? errors (#3784) (@john-bodley)
|
||||
- [#3797](https://github.com/apache/incubator-superset/pull/3797) [flake8] Resolve I??? errors (#3797) (@john-bodley)
|
||||
- [#3789](https://github.com/apache/incubator-superset/pull/3789) Add Lyft and Twitter to list of companies (#3789) (@mistercrunch)
|
||||
- [#3794](https://github.com/apache/incubator-superset/pull/3794) [time table] use sparkData values in tooltip (#3794) (@williaster)
|
||||
- [#3793](https://github.com/apache/incubator-superset/pull/3793) Adding back iso and correctly filtering iso from contrib total (#3793) (@michellethomas)
|
||||
- [#3788](https://github.com/apache/incubator-superset/pull/3788) Removing iso from data (#3788) (@michellethomas)
|
||||
- [#3778](https://github.com/apache/incubator-superset/pull/3778) [flake8] Resolving F8?? errors (#3778) (@john-bodley)
|
||||
- [#3785](https://github.com/apache/incubator-superset/pull/3785) Rename files to allow RPM build (#3785) (@SpyderRivera)
|
||||
- [#3783](https://github.com/apache/incubator-superset/pull/3783) [falke8] Resolving F6?? errors (#3783) (@john-bodley)
|
||||
- [#3529](https://github.com/apache/incubator-superset/pull/3529) [explore] using verbose_name in 'Time Column' control (#3529) (@mistercrunch)
|
||||
- [#3654](https://github.com/apache/incubator-superset/pull/3654) [Performance] VirtualizedSelect for SelectControl and FilterBox (#3654) (@Mogball)
|
||||
- [#3697](https://github.com/apache/incubator-superset/pull/3697) DI-1113. ADDENDUM. Authentication: Enable user impersonation for Superset to HiveServer2 using hive.server2.proxy.user (a.fernandez) (#3697) (@afernandez)
|
||||
|
||||
### 0.20.5 (2017/11/06 07:18 +00:00)
|
||||
- [#3776](https://github.com/apache/incubator-superset/pull/3776) [flake8] Enabling flake8 linting (#3776) (@john-bodley)
|
||||
- [#3774](https://github.com/apache/incubator-superset/pull/3774) [sql-lab] Fixing Run Query tooltip (#3774) (@john-bodley)
|
||||
- [#3773](https://github.com/apache/incubator-superset/pull/3773) Fix dashboard export download (#3773) (@michellethomas)
|
||||
- [#3767](https://github.com/apache/incubator-superset/pull/3767) [time table] add tooltip to sparkline (#3767) (@williaster)
|
||||
- [#3748](https://github.com/apache/incubator-superset/pull/3748) Update to reflect new version of cryptography (#3748) (@SpyderRivera)
|
||||
- [#3763](https://github.com/apache/incubator-superset/pull/3763) docs: reword the FAQ regarding table changes (#3763) (@xrmx)
|
||||
- [#3764](https://github.com/apache/incubator-superset/pull/3764) add stackoverflow tag (#3764) (@dmigo)
|
||||
- [#3759](https://github.com/apache/incubator-superset/pull/3759) Add dummy file to fix symlink (#3759) (@mistercrunch)
|
||||
- [#3751](https://github.com/apache/incubator-superset/pull/3751) fix https://github.com/apache/incubator-superset/pull/3726 (#3751) (@graceguo-supercat)
|
||||
- [#3750](https://github.com/apache/incubator-superset/pull/3750) Consolidate all translation config (#3750) (@alanmcruickshank)
|
||||
- [#3726](https://github.com/apache/incubator-superset/pull/3726) Bumping react-select to rc10 (#3726) (@mistercrunch)
|
||||
- [#3741](https://github.com/apache/incubator-superset/pull/3741) Fix has_table method (#3741) (@mxmzdlv)
|
||||
- [#3736](https://github.com/apache/incubator-superset/pull/3736) Escape columns names for time grains - postgres (#3736) (@Ryanthegiantlion)
|
||||
- [#3739](https://github.com/apache/incubator-superset/pull/3739) Fix 3657 (#3739) (@baldoalessandro)
|
||||
- [#3733](https://github.com/apache/incubator-superset/pull/3733) Using indexOf instead of includes for isXAxisString (#3733) (@michellethomas)
|
||||
- [#3723](https://github.com/apache/incubator-superset/pull/3723) bump react-bootstrap version (#3723) (@graceguo-supercat)
|
||||
- [#3721](https://github.com/apache/incubator-superset/pull/3721) Add CRUD action to refresh table metadata (#3721) (@mistercrunch)
|
||||
- [#3720](https://github.com/apache/incubator-superset/pull/3720) Validate JSON in slice's params on save (#3720) (@mistercrunch)
|
||||
- [#3722](https://github.com/apache/incubator-superset/pull/3722) Fix box_plot NaN issue (#3722) (@mistercrunch)
|
||||
- [#3715](https://github.com/apache/incubator-superset/pull/3715) Update messages.po (#3715) (@magicansk)
|
||||
- [#3686](https://github.com/apache/incubator-superset/pull/3686) Missing the data of one province and two regions of China (#3686) (@roganw)
|
||||
- [#3685](https://github.com/apache/incubator-superset/pull/3685) Fix the ISO code description of region/province/department (#3685) (@roganw)
|
||||
- [#3662](https://github.com/apache/incubator-superset/pull/3662) Set logging level to debug for DummyStatsLogger (#3662) (@mistercrunch)
|
||||
- [#3692](https://github.com/apache/incubator-superset/pull/3692) fixes for bugs in #3689 (#3692) (@Mogball)
|
||||
- [#3703](https://github.com/apache/incubator-superset/pull/3703) add VIPKID to the orgs. (#3703) (@killpanda)
|
||||
- [#3696](https://github.com/apache/incubator-superset/pull/3696) changed metric heading from h1 to h3 (#3696) (@Mogball)
|
||||
- [#3713](https://github.com/apache/incubator-superset/pull/3713) [translation] added japanese support (#3713) (@xiaoyugit)
|
||||
- [#3663](https://github.com/apache/incubator-superset/pull/3663) [minor] fix label showing description in time_table's URL (#3663) (@mistercrunch)
|
||||
- [#3711](https://github.com/apache/incubator-superset/pull/3711) fix the slice permission issue after user click-edit new slice title (#3711) (@graceguo-supercat)
|
||||
- [#3701](https://github.com/apache/incubator-superset/pull/3701) [form-data] Quoting form data (#3701) (@john-bodley)
|
||||
- [#3698](https://github.com/apache/incubator-superset/pull/3698) fixing the datasource inconsistence but in visualize flow (#3698) (@graceguo-supercat)
|
||||
- [#3683](https://github.com/apache/incubator-superset/pull/3683) [cleanup] removing print() artefacts (#3683) (@mistercrunch)
|
||||
- [#3702](https://github.com/apache/incubator-superset/pull/3702) Add support for IE 11 for markup slices (#3702) (@jaylindquist)
|
||||
- [#3693](https://github.com/apache/incubator-superset/pull/3693) defaultSort should be false when no sort is necessary (#3693) (@michellethomas)
|
||||
- [#3586](https://github.com/apache/incubator-superset/pull/3586) [Feature] Percentage columns in Table Viz (#3586) (@Mogball)
|
||||
- [#3652](https://github.com/apache/incubator-superset/pull/3652) DI-1113. Authentication: Enable user impersonation for Superset to HiveServer2 using hive.server2.proxy.user (a.fernandez) (#3652) (@afernandez)
|
||||
- [#3664](https://github.com/apache/incubator-superset/pull/3664) [minor] fix padding in Time Table (#3664) (@mistercrunch)
|
||||
- [#3678](https://github.com/apache/incubator-superset/pull/3678) unit tests for OptionDescription component (#3678) (@Mogball)
|
||||
- [#3679](https://github.com/apache/incubator-superset/pull/3679) Avoid dividing by zero for sparkline in time table viz (#3679) (@michellethomas)
|
||||
- [#3680](https://github.com/apache/incubator-superset/pull/3680) Sqllab error troubleshooting (#3680) (@timifasubaa)
|
||||
- [#3653](https://github.com/apache/incubator-superset/pull/3653) Add a ColorPickerControl (#3653) (@mistercrunch)
|
||||
- [#3642](https://github.com/apache/incubator-superset/pull/3642) [New Viz] Partition Diagram (#3642) (@Mogball)
|
||||
- [#3665](https://github.com/apache/incubator-superset/pull/3665) Add description for running specific test (#3665) (@timifasubaa)
|
||||
- [#3661](https://github.com/apache/incubator-superset/pull/3661) Making the sort order for metrics pull from fd for time table viz (#3661) (@michellethomas)
|
||||
- [#3417](https://github.com/apache/incubator-superset/pull/3417) Make columns that return an exception on click unsortable. (#3417) (@aliavni)
|
||||
- [#3651](https://github.com/apache/incubator-superset/pull/3651) Adding sort time table (#3651) (@michellethomas)
|
||||
- [#3647](https://github.com/apache/incubator-superset/pull/3647) added aihello as superset user. (#3647) (@ganeshkrishnan1)
|
||||
- [#3646](https://github.com/apache/incubator-superset/pull/3646) Fix #3612 - reverse sign in difference calculation (#3646) (@mistercrunch)
|
||||
- [#3648](https://github.com/apache/incubator-superset/pull/3648) Fixing some warnings during tests (#3648) (@dennybiasiolli)
|
||||
|
||||
### 0.20.4 (2017/10/12 04:04 +00:00)
|
||||
- [#3645](https://github.com/apache/incubator-superset/pull/3645) [Translations] Restored lost French translations (#3645) (@Mogball)
|
||||
- [#3644](https://github.com/apache/incubator-superset/pull/3644) [sql lab] fix impersonation + template issue (#3644) (@mistercrunch)
|
||||
- [#3641](https://github.com/apache/incubator-superset/pull/3641) Pin moment.js library since 2.19.0 creates problem (#3641) (@mistercrunch)
|
||||
- [#3600](https://github.com/apache/incubator-superset/pull/3600) [time_table] adding support for URLs / links (#3600) (@mistercrunch)
|
||||
- [#3626](https://github.com/apache/incubator-superset/pull/3626) Set tooltip to show extent of sparkData (#3626) (@michellethomas)
|
||||
- [#3631](https://github.com/apache/incubator-superset/pull/3631) add explicit message display for 'Fetching Annotation Layer' error (#3631) (@graceguo-supercat)
|
||||
- [#3637](https://github.com/apache/incubator-superset/pull/3637) [bugfix] Template rendering failed: '_AppCtxGlobals' object has no attribute 'user' (#3637) (@mistercrunch)
|
||||
- [#3638](https://github.com/apache/incubator-superset/pull/3638) fix long title text wrapping in editable-title component (#3638) (@graceguo-supercat)
|
||||
- [#3625](https://github.com/apache/incubator-superset/pull/3625) [minor] proper tooltip on ControlHeader's instant re-render trigger (#3625) (@mistercrunch)
|
||||
- [#3634](https://github.com/apache/incubator-superset/pull/3634) add annotation option and a linear color map for heatmap viz. (#3634) (@xiaoyugit)
|
||||
- [#3633](https://github.com/apache/incubator-superset/pull/3633) [bugfix] empty From date filter NoneType error (#3633) (@mistercrunch)
|
||||
- [#3621](https://github.com/apache/incubator-superset/pull/3621) remove unused imports (#3621) (@xrmx)
|
||||
- [#3611](https://github.com/apache/incubator-superset/pull/3611) fixing date/time filter keys (#3611) (@Mogball)
|
||||
|
||||
### 0.20.2 (2017/10/06 07:46 +00:00)
|
||||
- [#3606](https://github.com/apache/incubator-superset/pull/3606) [bugfix] #3593 'Chart Options' panel is missing (#3606) (@mistercrunch)
|
||||
- [#3601](https://github.com/apache/incubator-superset/pull/3601) Removing git artifact (#3601) (@mistercrunch)
|
||||
- [#3599](https://github.com/apache/incubator-superset/pull/3599) [hotfix] fixing issues around new time_table viz (#3599) (@mistercrunch)
|
||||
- [#3598](https://github.com/apache/incubator-superset/pull/3598) [hofix] work around circular deps (#3598) (@mistercrunch)
|
||||
- [#3597](https://github.com/apache/incubator-superset/pull/3597) [time table] fix reversed ratio (#3597) (@mistercrunch)
|
||||
- [#3508](https://github.com/apache/incubator-superset/pull/3508) [Feature/Bugfix] Datepicker and time granularity options to dashboard filters (#3508) (@Mogball)
|
||||
- [#3596](https://github.com/apache/incubator-superset/pull/3596) updating react-alert dependency to v2.3.0 (#3596) (@dennybiasiolli)
|
||||
- [#3577](https://github.com/apache/incubator-superset/pull/3577) [translations] generating missing strings (#3577) (@mistercrunch)
|
||||
- [#3478](https://github.com/apache/incubator-superset/pull/3478) [Bugfix/Feature] Fixed slice render staggering on dashboard first load (#3478) (@Mogball)
|
||||
- [#3543](https://github.com/apache/incubator-superset/pull/3543) New "Time Series - Table" visualization (#3543) (@mistercrunch)
|
||||
- [#3587](https://github.com/apache/incubator-superset/pull/3587) [sql lab] fix numeric sort in data table (#3587) (@mistercrunch)
|
||||
- [#3594](https://github.com/apache/incubator-superset/pull/3594) Fxing bug in label generation for multiple groupbys (#3594) (@fabianmenges)
|
||||
- [#3591](https://github.com/apache/incubator-superset/pull/3591) update immutable.js to v3.8.2 (MIT license) (#3591) (@naoyak)
|
||||
- [#3571](https://github.com/apache/incubator-superset/pull/3571) [Feature] Copy-to-clipboard button in View Query (#3571) (@Mogball)
|
||||
- [#3585](https://github.com/apache/incubator-superset/pull/3585) Allow users to see query string when query returns no data (#3585) (@Mogball)
|
||||
- [#3582](https://github.com/apache/incubator-superset/pull/3582) [Bugfix]: Explore view does not respect custom timeout. (#3582) (@fabianmenges)
|
||||
- [#3584](https://github.com/apache/incubator-superset/pull/3584) Fixed creating new filter options in FilterBox (#3584) (@Mogball)
|
||||
- [#3562](https://github.com/apache/incubator-superset/pull/3562) Added custom pasteSelect to handle paste events (#3562) (@Mogball)
|
||||
- [#3569](https://github.com/apache/incubator-superset/pull/3569) Bumping React to 15.6.2 (MIT license) (#3569) (@mistercrunch)
|
||||
|
||||
### 0.20.1 (2017/10/03 07:04 +00:00)
|
||||
- [#3576](https://github.com/apache/incubator-superset/pull/3576) v0.20.1 (#3576) (@mistercrunch)
|
||||
- [#3572](https://github.com/apache/incubator-superset/pull/3572) After saving slice fixing redirect (#3572) (@michellethomas)
|
||||
- [#3565](https://github.com/apache/incubator-superset/pull/3565) Added label+percent and label+value display options to pie chart (#3565) (@Mogball)
|
||||
- [#3567](https://github.com/apache/incubator-superset/pull/3567) Removing yarn warnings during install (#3567) (@dennybiasiolli)
|
||||
- [#3563](https://github.com/apache/incubator-superset/pull/3563) [nvd3] fix single metric showing up in legend (#3563) (@mistercrunch)
|
||||
- [#3558](https://github.com/apache/incubator-superset/pull/3558) Add Pronto Tools to user list (#3558) (@zkan)
|
||||
- [#3553](https://github.com/apache/incubator-superset/pull/3553) Minor documentation fix (#3553) (@gaborhermann)
|
||||
- [#3545](https://github.com/apache/incubator-superset/pull/3545) CHANGELOG for 0.20.0 (#3545) (@mistercrunch)
|
||||
- [#3534](https://github.com/apache/incubator-superset/pull/3534) Explore update button labels (#3534) (@timifasubaa)
|
||||
- [#3547](https://github.com/apache/incubator-superset/pull/3547) Fixing missing messages.json file (#3547) (@mistercrunch)
|
||||
|
||||
### 0.20.0 (2017/09/28 04:26 +00:00)
|
||||
- [#3528](https://github.com/apache/incubator-superset/pull/3528) try to fix problem that chrome window not opening after ajax requrest (#3528) (@graceguo-supercat)
|
||||
- [#3521](https://github.com/apache/incubator-superset/pull/3521) Time Series Annotation Layers (#3521) (@graceguo-supercat)
|
||||
|
||||
84
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Code of Conduct
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
A primary goal of Apache Superset is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof).
|
||||
|
||||
This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior.
|
||||
|
||||
We invite all those who participate in Apache Superset to help us create safe and positive experiences for everyone.
|
||||
|
||||
## 2. Open Source Citizenship
|
||||
|
||||
A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community.
|
||||
|
||||
Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society.
|
||||
|
||||
If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know.
|
||||
|
||||
## 3. Expected Behavior
|
||||
|
||||
The following behaviors are expected and requested of all community members:
|
||||
|
||||
* Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community.
|
||||
* Exercise consideration and respect in your speech and actions.
|
||||
* Attempt collaboration before conflict.
|
||||
* Refrain from demeaning, discriminatory, or harassing behavior and speech.
|
||||
* Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.
|
||||
* Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations.
|
||||
|
||||
## 4. Unacceptable Behavior
|
||||
|
||||
The following behaviors are considered harassment and are unacceptable within our community:
|
||||
|
||||
* Violence, threats of violence or violent language directed against another person.
|
||||
* Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language.
|
||||
* Posting or displaying sexually explicit or violent material.
|
||||
* Posting or threatening to post other people’s personally identifying information ("doxing").
|
||||
* Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability.
|
||||
* Inappropriate photography or recording.
|
||||
* Inappropriate physical contact. You should have someone’s consent before touching them.
|
||||
* Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances.
|
||||
* Deliberate intimidation, stalking or following (online or in person).
|
||||
* Advocating for, or encouraging, any of the above behavior.
|
||||
* Sustained disruption of community events, including talks and presentations.
|
||||
|
||||
## 5. Consequences of Unacceptable Behavior
|
||||
|
||||
Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated.
|
||||
|
||||
Anyone asked to stop unacceptable behavior is expected to comply immediately.
|
||||
|
||||
If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event).
|
||||
|
||||
## 6. Reporting Guidelines
|
||||
|
||||
If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. dev@superset.incubator.apache.org .
|
||||
|
||||
|
||||
|
||||
Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress.
|
||||
|
||||
## 7. Addressing Grievances
|
||||
|
||||
If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify Apache with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies.
|
||||
|
||||
|
||||
|
||||
## 8. Scope
|
||||
|
||||
We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business.
|
||||
|
||||
This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members.
|
||||
|
||||
## 9. Contact info
|
||||
|
||||
dev@superset.incubator.apache.org
|
||||
|
||||
## 10. License and attribution
|
||||
|
||||
This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/).
|
||||
|
||||
Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy).
|
||||
|
||||
Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/)
|
||||
@@ -49,6 +49,10 @@ If you are proposing a feature:
|
||||
implement.
|
||||
- Remember that this is a volunteer-driven project, and that
|
||||
contributions are welcome :)
|
||||
|
||||
### Questions
|
||||
|
||||
There is a dedicated [tag](https://stackoverflow.com/questions/tagged/apache-superset) on [stackoverflow](https://stackoverflow.com/). Please use it when asking questions.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
@@ -262,9 +266,15 @@ Before running python unit tests, please setup local testing environment:
|
||||
pip install -r dev-reqs.txt
|
||||
```
|
||||
|
||||
Python tests can be run with:
|
||||
All python tests can be run with:
|
||||
|
||||
./run_tests.sh
|
||||
|
||||
Alternatively, you can run a specific test with:
|
||||
|
||||
./run_specific_test.sh tests.core_tests:CoreTests.test_function_name
|
||||
|
||||
Note that before running specific tests, you have to both setup the local testing environment and run all tests.
|
||||
|
||||
We use [Mocha](https://mochajs.org/), [Chai](http://chaijs.com/) and [Enzyme](http://airbnb.io/enzyme/) to test Javascript. Tests can be run with:
|
||||
|
||||
@@ -276,9 +286,8 @@ We use [Mocha](https://mochajs.org/), [Chai](http://chaijs.com/) and [Enzyme](ht
|
||||
|
||||
Lint the project with:
|
||||
|
||||
# for python changes
|
||||
flake8 changes tests
|
||||
flake8 changes superset
|
||||
# for python
|
||||
flake8
|
||||
|
||||
# for javascript
|
||||
npm run lint
|
||||
@@ -345,14 +354,15 @@ navigation bar.
|
||||
}
|
||||
|
||||
As per the [Flask AppBuilder documentation] about translation, to create a
|
||||
new language dictionary, run the following command:
|
||||
new language dictionary, run the following command (where `es` is replaced with
|
||||
the language code for your target language):
|
||||
|
||||
pybabel init -i ./babel/messages.pot -d superset/translations -l es
|
||||
pybabel init -i superset/translations/messages.pot -d superset/translations -l es
|
||||
|
||||
Then it's a matter of running the statement below to gather all strings that
|
||||
need translation
|
||||
|
||||
fabmanager babel-extract --target superset/translations/ -k _ -k __ -k t -k tn -k tct
|
||||
fabmanager babel-extract --target superset/translations/ --output superset/translations/messages.pot --config superset/translations/babel.cfg -k _ -k __ -k t -k tn -k tct
|
||||
|
||||
You can then translate the strings gathered in files located under
|
||||
`superset/translation`, where there's one per language. For the translations
|
||||
@@ -369,6 +379,11 @@ Execute this command to convert the en PO file into a json file:
|
||||
|
||||
po2json -d superset -f jed1.x superset/translations/en/LC_MESSAGES/messages.po superset/translations/en/LC_MESSAGES/messages.json
|
||||
|
||||
If you get errors running `po2json`, you might be running the ubuntu package with the same
|
||||
name rather than the nodejs package (they have a different format for the arguments). You
|
||||
need to be running the nodejs version, and so if there is a conflict you may need to point
|
||||
directly at `/usr/local/bin/po2json` rather than just `po2json`.
|
||||
|
||||
## Adding new datasources
|
||||
|
||||
1. Create Models and Views for the datasource, add them under superset folder, like a new my_models.py
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
recursive-include superset/templates *
|
||||
recursive-include superset/data *
|
||||
recursive-include superset/migrations *
|
||||
recursive-include superset/static *
|
||||
recursive-exclude superset/static/docs *
|
||||
recursive-exclude superset/static/spec *
|
||||
recursive-exclude superset/static/assets/node_modules *
|
||||
recursive-include superset/templates *
|
||||
recursive-include superset/translations *
|
||||
recursive-exclude tests *
|
||||
recursive-include superset/data *
|
||||
recursive-include superset/migrations *
|
||||
|
||||
102
README.md
@@ -6,7 +6,7 @@ Superset
|
||||
[](https://coveralls.io/github/apache/incubator-superset?branch=master)
|
||||
[](https://pypi.python.org/pypi/superset)
|
||||
[](https://requires.io/github/apache/incubator-superset/requirements/?branch=master)
|
||||
[](https://gitter.im/apache/incubator-superset?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://gitter.im/airbnb/superset?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](https://superset.incubator.apache.org)
|
||||
[](https://david-dm.org/apache/incubator-superset?path=superset/assets)
|
||||
|
||||
@@ -127,6 +127,59 @@ Installation & Configuration
|
||||
[See in the documentation](https://superset.incubator.apache.org/installation.html)
|
||||
|
||||
|
||||
Resources
|
||||
-------------
|
||||
* [Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org/)
|
||||
* [Gitter (live chat) Channel](https://gitter.im/airbnb/superset)
|
||||
* [Docker image](https://hub.docker.com/r/amancevice/superset/) (community contributed)
|
||||
* [Slides from Strata (March 2016)](https://drive.google.com/open?id=0B5PVE0gzO81oOVJkdF9aNkJMSmM)
|
||||
* [Stackoverflow tag](https://stackoverflow.com/questions/tagged/apache-superset)
|
||||
* [DEPRECATED Google Group](https://groups.google.com/forum/#!forum/airbnb_superset)
|
||||
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
Interested in contributing? Casual hacking? Check out
|
||||
[Contributing.MD](https://github.com/airbnb/superset/blob/master/CONTRIBUTING.md)
|
||||
|
||||
|
||||
Who uses Apache Superset (incubating)?
|
||||
--------------------------------------
|
||||
|
||||
Here's a list of organizations who have taken the time to send a PR to let
|
||||
the world know they are using Superset. Join our growing community!
|
||||
|
||||
- [AiHello](https://www.aihello.com)
|
||||
- [Airbnb](https://github.com/airbnb)
|
||||
- [Amino](https://amino.com)
|
||||
- [Brilliant.org](https://brilliant.org/)
|
||||
- [Capital Service S.A.](http://capitalservice.pl)
|
||||
- [Clark.de](http://clark.de/)
|
||||
- [Digit Game Studios](https://www.digitgaming.com/)
|
||||
- [Douban](https://www.douban.com/)
|
||||
- [Endress+Hauser](http://www.endress.com/)
|
||||
- [FBK - ICT center](http://ict.fbk.eu)
|
||||
- [Faasos](http://faasos.com/)
|
||||
- [GfK Data Lab](http://datalab.gfk.com)
|
||||
- [Konfío](http://konfio.mx)
|
||||
- [Lyft](https://www.lyft.com/)
|
||||
- [Maieutical Labs](https://cloudschooling.it)
|
||||
- [Ona](https://ona.io)
|
||||
- [Pronto Tools](http://www.prontotools.io)
|
||||
- [Qunar](https://www.qunar.com/)
|
||||
- [Shopee](https://shopee.sg)
|
||||
- [Shopkick](https://www.shopkick.com)
|
||||
- [Tails.com](https://tails.com)
|
||||
- [Tobii](http://www.tobii.com/)
|
||||
- [Tooploox](https://www.tooploox.com/)
|
||||
- [Twitter](https://twitter.com/)
|
||||
- [Udemy](https://www.udemy.com/)
|
||||
- [VIPKID](https://www.vipkid.com.cn/)
|
||||
- [Yahoo!](https://yahoo.com/)
|
||||
- [Zalando](https://www.zalando.com)
|
||||
|
||||
|
||||
More screenshots
|
||||
----------------
|
||||
|
||||
@@ -145,50 +198,3 @@ More screenshots
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
Resources
|
||||
-------------
|
||||
* [Superset Google Group](https://groups.google.com/forum/#!forum/airbnb_superset)
|
||||
* [Gitter (live chat) Channel](https://gitter.im/airbnb/superset)
|
||||
* [Docker image](https://hub.docker.com/r/amancevice/superset/) (community contributed)
|
||||
* [Slides from Strata (March 2016)](https://drive.google.com/open?id=0B5PVE0gzO81oOVJkdF9aNkJMSmM)
|
||||
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
Interested in contributing? Casual hacking? Check out
|
||||
[Contributing.MD](https://github.com/airbnb/superset/blob/master/CONTRIBUTING.md)
|
||||
|
||||
|
||||
Who uses Apache Superset (incubating)?
|
||||
--------------------------------------
|
||||
|
||||
Here's a list of organizations who have taken the time to send a PR to let
|
||||
the world know they are using Superset. Join our growing community!
|
||||
|
||||
- [Airbnb](https://github.com/airbnb)
|
||||
- [Amino](https://amino.com)
|
||||
- [Brilliant.org](https://brilliant.org/)
|
||||
- [Capital Service S.A.](http://capitalservice.pl)
|
||||
- [Clark.de](http://clark.de/)
|
||||
- [Digit Game Studios](https://www.digitgaming.com/)
|
||||
- [Douban](https://www.douban.com/)
|
||||
- [Endress+Hauser](http://www.endress.com/)
|
||||
- [FBK - ICT center](http://ict.fbk.eu)
|
||||
- [Faasos](http://faasos.com/)
|
||||
- [GfK Data Lab](http://datalab.gfk.com)
|
||||
- [Konfío](http://konfio.mx)
|
||||
- [Maieutical Labs](https://cloudschooling.it)
|
||||
- [Pronto Tools](http://www.prontotools.io)
|
||||
- [Qunar](https://www.qunar.com/)
|
||||
- [Shopee](https://shopee.sg)
|
||||
- [Shopkick](https://www.shopkick.com)
|
||||
- [Tails.com](https://tails.com)
|
||||
- [Tobii](http://www.tobii.com/)
|
||||
- [Tooploox](https://www.tooploox.com/)
|
||||
- [Udemy](https://www.udemy.com/)
|
||||
- [Yahoo!](https://yahoo.com/)
|
||||
- [Zalando](https://www.zalando.com)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ codeclimate-test-reporter
|
||||
coveralls
|
||||
flake8
|
||||
flask_cors
|
||||
ipdb
|
||||
mock
|
||||
mysqlclient
|
||||
nose
|
||||
|
||||
1
docs/_build/html/README.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Folder containing the sphinx-generated documentation
|
||||
41
docs/faq.rst
@@ -45,6 +45,13 @@ visualizations.
|
||||
https://github.com/airbnb/superset/issues?q=label%3Aexample+is%3Aclosed
|
||||
|
||||
|
||||
Can I upload and visualize csv data?
|
||||
------------------------------------
|
||||
|
||||
Yes, using the ``Upload a CSV`` button under the ``Sources``
|
||||
menu item. This brings up a form that allows you specify required information. After creating the table from CSV, it can then be loaded like any other on the ``Sources -> Tables``page.
|
||||
|
||||
|
||||
Why are my queries timing out?
|
||||
------------------------------
|
||||
|
||||
@@ -99,7 +106,7 @@ edit the ``JSON Metadata`` field, more specifically the
|
||||
never be affected by any dashboard level filtering.
|
||||
|
||||
|
||||
..code::
|
||||
..code:: json
|
||||
|
||||
{
|
||||
"filter_immune_slices": [324, 65, 92],
|
||||
@@ -134,7 +141,7 @@ to be refreshed - especially if some data is slow moving, or run heavy queries.
|
||||
slices from the timed refresh process, add the ``timed_refresh_immune_slices`` key to the dashboard
|
||||
``JSON Metadata`` field:
|
||||
|
||||
..code::
|
||||
..code:: json
|
||||
|
||||
{
|
||||
"filter_immune_slices": [],
|
||||
@@ -150,7 +157,7 @@ Slice refresh will also be staggered over the specified period. You can turn off
|
||||
by setting the ``stagger_refresh`` to ``false`` and modify the stagger period by setting
|
||||
``stagger_time`` to a value in milliseconds in the ``JSON Metadata`` field:
|
||||
|
||||
..code::
|
||||
..code:: json
|
||||
|
||||
{
|
||||
"stagger_refresh": false,
|
||||
@@ -170,8 +177,8 @@ You can override this path using the ``SUPERSET_HOME`` environment variable.
|
||||
|
||||
Another work around is to change where superset stores the sqlite database by adding ``SQLALCHEMY_DATABASE_URI = 'sqlite:////new/location/superset.db'`` in superset_config.py (create the file if needed), then adding the directory where superset_config.py lives to PYTHONPATH environment variable (e.g. ``export PYTHONPATH=/opt/logs/sandbox/airbnb/``).
|
||||
|
||||
How do I add new columns to an existing table
|
||||
---------------------------------------------
|
||||
What if the table schema changed?
|
||||
---------------------------------
|
||||
|
||||
Table schemas evolve, and Superset needs to reflect that. It's pretty common
|
||||
in the life cycle of a dashboard to want to add a new dimension or metric.
|
||||
@@ -213,3 +220,27 @@ How can I set a default filter on my dashboard?
|
||||
|
||||
Easy. Simply apply the filter and save the dashboard while the filter
|
||||
is active.
|
||||
|
||||
How do I get Superset to refresh the schema of my table?
|
||||
--------------------------------------------------------
|
||||
|
||||
When adding columns to a table, you can have Superset detect and merge the
|
||||
new columns in by using the "Refresh Metadata" action in the
|
||||
``Source -> Tables`` page. Simply check the box next to the tables
|
||||
you want the schema refreshed, and click ``Actions -> Refresh Metadata``.
|
||||
|
||||
Is there a way to force the use specific colors?
|
||||
------------------------------------------------
|
||||
|
||||
It is possible on a per-dashboard basis by providing a mapping of
|
||||
labels to colors in the ``JSON Metadata`` attribute using the
|
||||
``label_colors`` key.
|
||||
|
||||
..code:: json
|
||||
|
||||
{
|
||||
"label_colors": {
|
||||
"Girls": "#FF69B4",
|
||||
"Boys": "#ADD8E6"
|
||||
}
|
||||
}
|
||||
|
||||
103
docs/import_export_datasources.rst
Normal file
@@ -0,0 +1,103 @@
|
||||
Importing and Exporting Datasources
|
||||
===================================
|
||||
|
||||
The superset cli allows you to import and export datasources from and to YAML.
|
||||
Datasources include both databases and druid clusters. The data is expected to be organized in the following hierarchy: ::
|
||||
|
||||
.
|
||||
├──databases
|
||||
| ├──database_1
|
||||
| | ├──table_1
|
||||
| | | ├──columns
|
||||
| | | | ├──column_1
|
||||
| | | | ├──column_2
|
||||
| | | | └──... (more columns)
|
||||
| | | └──metrics
|
||||
| | | ├──metric_1
|
||||
| | | ├──metric_2
|
||||
| | | └──... (more metrics)
|
||||
| | └── ... (more tables)
|
||||
| └── ... (more databases)
|
||||
└──druid_clusters
|
||||
├──cluster_1
|
||||
| ├──datasource_1
|
||||
| | ├──columns
|
||||
| | | ├──column_1
|
||||
| | | ├──column_2
|
||||
| | | └──... (more columns)
|
||||
| | └──metrics
|
||||
| | ├──metric_1
|
||||
| | ├──metric_2
|
||||
| | └──... (more metrics)
|
||||
| └── ... (more datasources)
|
||||
└── ... (more clusters)
|
||||
|
||||
|
||||
Exporting Datasources to YAML
|
||||
-----------------------------
|
||||
You can print your current datasources to stdout by running: ::
|
||||
|
||||
superset export_datasources
|
||||
|
||||
|
||||
To save your datasources to a file run: ::
|
||||
|
||||
superset export_datasources -f <filename>
|
||||
|
||||
|
||||
By default, default (null) values will be omitted. Use the ``-d`` flag to include them.
|
||||
If you want back references to be included (e.g. a column to include the table id
|
||||
it belongs to) use the ``-b`` flag.
|
||||
|
||||
Alternatively you can export datasources using the UI: ::
|
||||
|
||||
1. Open **Sources** -> **Databases** to export all tables associated to a single or multiple databases. (**Tables** for one or more tables, **Druid Clusters** for clusters, **Druid Datasources** for datasources)
|
||||
2. Select the items you would like to export
|
||||
3. Click **Actions** -> **Export to YAML**
|
||||
4. If you want to import an item that you exported through the UI, you will need to nest it inside its parent element, e.g. a `database` needs to be nested under `databases` a `table` needs to be nested inside a `database` element.
|
||||
|
||||
Exporting the complete supported YAML schema
|
||||
--------------------------------------------
|
||||
In order to obtain an exhaustive list of all fields you can import using the YAML import run: ::
|
||||
|
||||
superset export_datasource_schema
|
||||
|
||||
Again, you can use the ``-b`` flag to include back references.
|
||||
|
||||
|
||||
Importing Datasources from YAML
|
||||
-------------------------------
|
||||
In order to import datasources from a YAML file(s), run: ::
|
||||
|
||||
superset import_datasources -p <path or filename>
|
||||
|
||||
If you supply a path all files ending with ``*.yaml`` or ``*.yml`` will be parsed.
|
||||
You can apply additional flags e.g.: ::
|
||||
|
||||
superset import_datasources -p <path> -r
|
||||
|
||||
Will search the supplied path recursively.
|
||||
|
||||
The sync flag ``-s`` takes parameters in order to sync the supplied elements with
|
||||
your file. Be careful this can delete the contents of your meta database. Example:
|
||||
|
||||
superset import_datasources -p <path / filename> -s columns,metrics
|
||||
|
||||
This will sync all ``metrics`` and ``columns`` for all datasources found in the
|
||||
``<path / filename>`` in the Superset meta database. This means columns and metrics
|
||||
not specified in YAML will be deleted. If you would add ``tables`` to ``columns,metrics``
|
||||
those would be synchronised as well.
|
||||
|
||||
|
||||
If you don't supply the sync flag (``-s``) importing will only add and update (override) fields.
|
||||
E.g. you can add a ``verbose_name`` to the the column ``ds`` in the table ``random_time_series`` from the example datasets
|
||||
by saving the following YAML to file and then running the ``import_datasources`` command. ::
|
||||
|
||||
databases:
|
||||
- database_name: main
|
||||
tables:
|
||||
- table_name: random_time_series
|
||||
columns:
|
||||
- column_name: ds
|
||||
verbose_name: datetime
|
||||
|
||||
@@ -25,10 +25,10 @@ intelligence web application
|
||||
endorsed by the ASF.
|
||||
|
||||
Overview
|
||||
=======================================
|
||||
========
|
||||
|
||||
Features
|
||||
---------
|
||||
--------
|
||||
|
||||
- A rich set of data visualizations
|
||||
- An easy-to-use interface for exploring and visualizing data
|
||||
@@ -61,7 +61,7 @@ Features
|
||||
|
||||
|
||||
Contents
|
||||
---------
|
||||
--------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
@@ -53,6 +53,12 @@ the required dependencies are installed: ::
|
||||
|
||||
sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev
|
||||
|
||||
**Ubuntu 16.04** If you have python3.5 installed alongside with python2.7, as is default on **Ubuntu 16.04 LTS**, run this command also
|
||||
|
||||
sudo apt-get install build-essential libssl-dev libffi-dev python3.5-dev python-pip libsasl2-dev libldap2-dev
|
||||
|
||||
otherwhise build for ``cryptography`` fails.
|
||||
|
||||
For **Fedora** and **RHEL-derivatives**, the following command will ensure
|
||||
that the required dependencies are installed: ::
|
||||
|
||||
@@ -62,7 +68,7 @@ that the required dependencies are installed: ::
|
||||
**OSX**, system python is not recommended. brew's python also ships with pip ::
|
||||
|
||||
brew install pkg-config libffi openssl python
|
||||
env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" pip install cryptography==1.7.2
|
||||
env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" pip install cryptography==1.9
|
||||
|
||||
**Windows** isn't officially supported at this point, but if you want to
|
||||
attempt it, download `get-pip.py <https://bootstrap.pypa.io/get-pip.py>`_, and run ``python get-pip.py`` which may need admin access. Then run the following: ::
|
||||
@@ -148,7 +154,7 @@ around `gunicorn`, it doesn't expose all the options you may need,
|
||||
so you'll want to craft your own `gunicorn` command in your production
|
||||
environment. Here's an **async** setup known to work well: ::
|
||||
|
||||
gunicorn \
|
||||
gunicorn \
|
||||
-w 10 \
|
||||
-k gevent \
|
||||
--timeout 120 \
|
||||
@@ -163,10 +169,31 @@ Refer to the
|
||||
for more information.
|
||||
|
||||
Note that *gunicorn* does not
|
||||
work on Windows so the `superser runserver` command is not expected to work
|
||||
work on Windows so the `superset runserver` command is not expected to work
|
||||
in that context. Also note that the development web
|
||||
server (`superset runserver -d`) is not intended for production use.
|
||||
|
||||
Flask-AppBuilder Permissions
|
||||
----------------------------
|
||||
|
||||
By default every time the Flask-AppBuilder (FAB) app is initialized the
|
||||
permissions and views are added automatically to the backend and associated with
|
||||
the ‘Admin’ role. The issue however is when you are running multiple concurrent
|
||||
workers this creates a lot of contention and race conditions when defining
|
||||
permissions and views.
|
||||
|
||||
To alleviate this issue, the automatic updating of permissions can be disabled
|
||||
by setting the :envvar:`SUPERSET_UPDATE_PERMS` environment variable to `0`.
|
||||
The value `1` enables it, `0` disables it. Note if undefined the functionality
|
||||
is enabled to maintain backwards compatibility.
|
||||
|
||||
In a production environment initialization could take on the following form:
|
||||
|
||||
export SUPERSET_UPDATE_PERMS=1
|
||||
superset init
|
||||
|
||||
export SUPERSET_UPDATE_PERMS=0
|
||||
gunicorn -w 10 ... superset:app
|
||||
|
||||
Configuration behind a load balancer
|
||||
------------------------------------
|
||||
@@ -181,6 +208,11 @@ If the load balancer is inserting X-Forwarded-For/X-Forwarded-Proto headers, you
|
||||
should set `ENABLE_PROXY_FIX = True` in the superset config file to extract and use
|
||||
the headers.
|
||||
|
||||
In case that the reverse proxy is used for providing ssl encryption,
|
||||
an explicit definition of the `X-Forwarded-Proto` may be required.
|
||||
For the Apache webserver this can be set as follows: ::
|
||||
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
@@ -277,6 +309,8 @@ Here's a list of some of the recommended packages.
|
||||
| ClickHouse | ``pip install | ``clickhouse://`` |
|
||||
| | sqlalchemy-clickhouse`` | |
|
||||
+---------------+-------------------------------------+-------------------------------------------------+
|
||||
| Kylin | ``pip install kylinpy`` | ``kylin://`` |
|
||||
+---------------+-------------------------------------+-------------------------------------------------+
|
||||
|
||||
Note that many other database are supported, the main criteria being the
|
||||
existence of a functional SqlAlchemy dialect and Python driver. Googling
|
||||
@@ -394,7 +428,7 @@ metadata from your Druid cluster(s)
|
||||
|
||||
|
||||
CORS
|
||||
-----
|
||||
----
|
||||
|
||||
The extra CORS Dependency must be installed:
|
||||
|
||||
@@ -498,6 +532,11 @@ look something like:
|
||||
RESULTS_BACKEND = RedisCache(
|
||||
host='localhost', port=6379, key_prefix='superset_results')
|
||||
|
||||
Note that it's important that all the worker nodes and web servers in
|
||||
the Superset cluster share a common metadata database.
|
||||
This means that SQLite will not work in this context since it has
|
||||
limited support for concurrency and
|
||||
typically lives on the local file system.
|
||||
|
||||
Also note that SQL Lab supports Jinja templating in queries, and that it's
|
||||
possible to overload
|
||||
@@ -550,3 +589,20 @@ same server.
|
||||
return "Ok"
|
||||
|
||||
BLUEPRINTS = [simple_page]
|
||||
|
||||
StatsD logging
|
||||
--------------
|
||||
|
||||
Superset is instrumented to log events to StatsD if desired. Most endpoints hit
|
||||
are logged as well as key events like query start and end in SQL Lab.
|
||||
|
||||
To setup StatsD logging, it's a matter of configuring the logger in your
|
||||
``superset_config.py``.
|
||||
|
||||
..code ::
|
||||
|
||||
from superset.stats_logger import StatsdStatsLogger
|
||||
STATS_LOGGER = StatsdStatsLogger(host='localhost', port=8125, prefix='superset')
|
||||
|
||||
Note that it's also possible to implement you own logger by deriving
|
||||
``superset.stats_logger.BaseStatsLogger``.
|
||||
|
||||
@@ -10,7 +10,7 @@ Provided Roles
|
||||
--------------
|
||||
Superset ships with a set of roles that are handled by Superset itself.
|
||||
You can assume that these roles will stay up-to-date as Superset evolves.
|
||||
Even though it's possible for ``Admin`` usrs to do so, it is not recommended
|
||||
Even though it's possible for ``Admin`` users to do so, it is not recommended
|
||||
that you alter these roles in any way by removing
|
||||
or adding permissions to them as these roles will be re-synchronized to
|
||||
their original values as you run your next ``superset init`` command.
|
||||
|
||||
@@ -48,17 +48,25 @@ Available macros
|
||||
|
||||
We expose certain modules from Python's standard library in
|
||||
Superset's Jinja context:
|
||||
|
||||
- ``time``: ``time``
|
||||
- ``datetime``: ``datetime.datetime``
|
||||
- ``uuid``: ``uuid``
|
||||
- ``random``: ``random``
|
||||
- ``relativedelta``: ``dateutil.relativedelta.relativedelta``
|
||||
- more to come!
|
||||
|
||||
`Jinja's builtin filters <http://jinja.pocoo.org/docs/dev/templates/>`_ can be also be applied where needed.
|
||||
|
||||
|
||||
.. autoclass:: superset.jinja_context.PrestoTemplateProcessor
|
||||
:members:
|
||||
|
||||
.. autofunction:: superset.jinja_context.url_param
|
||||
|
||||
Extending macros
|
||||
''''''''''''''''
|
||||
|
||||
As mentioned in the `Installation & Configuration <https://superset.incubator.apache.org/installation.html#installation-configuration>`_ documentation,
|
||||
it's possible for administrators to expose more more macros in their
|
||||
environment using the configuration variable ``JINJA_CONTEXT_ADDONS``.
|
||||
All objects referenced in this dictionary will become available for users
|
||||
to integrate in their queries in **SQL Lab**.
|
||||
|
||||
@@ -23,7 +23,7 @@ Under the **Sources** menu, select the *Databases* option:
|
||||
.. image:: _static/img/tutorial/tutorial_01_sources_database.png
|
||||
:scale: 70%
|
||||
|
||||
On the resulting page, click on the green plus sign, near the top left:
|
||||
On the resulting page, click on the green plus sign, near the top right:
|
||||
|
||||
.. image:: _static/img/tutorial/tutorial_02_add_database.png
|
||||
:scale: 70%
|
||||
|
||||
@@ -9,4 +9,6 @@ set -e
|
||||
superset/bin/superset db upgrade
|
||||
superset/bin/superset version -v
|
||||
python setup.py nosetests
|
||||
coveralls
|
||||
if [ "$CI" = "true" ] ; then
|
||||
coveralls
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from superset import sm
|
||||
from collections import defaultdict
|
||||
|
||||
from superset import sm
|
||||
|
||||
|
||||
def cleanup_permissions():
|
||||
# 1. Clean up duplicates.
|
||||
|
||||
@@ -23,6 +23,3 @@ detailed-errors=1
|
||||
with-coverage=1
|
||||
nocapture=1
|
||||
cover-package=superset
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length=90
|
||||
|
||||
63
setup.py
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
PACKAGE_DIR = os.path.join(BASE_DIR, 'superset', 'static', 'assets')
|
||||
@@ -14,18 +15,19 @@ def get_git_sha():
|
||||
try:
|
||||
s = str(subprocess.check_output(['git', 'rev-parse', 'HEAD']))
|
||||
return s.strip()
|
||||
except:
|
||||
return ""
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
GIT_SHA = get_git_sha()
|
||||
version_info = {
|
||||
'GIT_SHA': GIT_SHA,
|
||||
'version': version_string,
|
||||
}
|
||||
print("-==-" * 15)
|
||||
print("VERSION: " + version_string)
|
||||
print("GIT SHA: " + GIT_SHA)
|
||||
print("-==-" * 15)
|
||||
print('-==-' * 15)
|
||||
print('VERSION: ' + version_string)
|
||||
print('GIT SHA: ' + GIT_SHA)
|
||||
print('-==-' * 15)
|
||||
|
||||
with open(os.path.join(PACKAGE_DIR, 'version_info.json'), 'w') as version_file:
|
||||
json.dump(version_info, version_file)
|
||||
@@ -34,45 +36,50 @@ with open(os.path.join(PACKAGE_DIR, 'version_info.json'), 'w') as version_file:
|
||||
setup(
|
||||
name='superset',
|
||||
description=(
|
||||
"A interactive data visualization platform build on SqlAlchemy "
|
||||
"and druid.io"),
|
||||
'A interactive data visualization platform build on SqlAlchemy '
|
||||
'and druid.io'),
|
||||
version=version_string,
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
scripts=['superset/bin/superset'],
|
||||
install_requires=[
|
||||
'boto3==1.4.4',
|
||||
'celery==3.1.25',
|
||||
'boto3>=1.4.6',
|
||||
'celery==4.1.0',
|
||||
'colorama==0.3.9',
|
||||
'cryptography==1.9',
|
||||
'flask==0.12.2',
|
||||
'flask-appbuilder==1.9.4',
|
||||
'flask-appbuilder==1.9.6',
|
||||
'flask-cache==0.13.1',
|
||||
'flask-migrate==2.0.3',
|
||||
'flask-script==2.0.5',
|
||||
'flask-migrate==2.1.1',
|
||||
'flask-script==2.0.6',
|
||||
'flask-sqlalchemy==2.1',
|
||||
'flask-testing==0.6.2',
|
||||
'flask-testing==0.7.1',
|
||||
'flask-wtf==0.14.2',
|
||||
'flower==0.9.1',
|
||||
'flower==0.9.2',
|
||||
'future>=0.16.0, <0.17',
|
||||
'python-geohash==0.8.5',
|
||||
'humanize==0.5.1',
|
||||
'gunicorn==19.7.1',
|
||||
'idna==2.5',
|
||||
'markdown==2.6.8',
|
||||
'pandas==0.20.3',
|
||||
'idna==2.6',
|
||||
'markdown==2.6.11',
|
||||
'pandas==0.22.0',
|
||||
'parsedatetime==2.0.0',
|
||||
'pydruid==0.3.1',
|
||||
'pathlib2==2.3.0',
|
||||
'polyline==1.3.2',
|
||||
'pydruid==0.4.0',
|
||||
'PyHive>=0.4.0',
|
||||
'python-dateutil==2.6.0',
|
||||
'requests==2.17.3',
|
||||
'simplejson==3.10.0',
|
||||
'six==1.10.0',
|
||||
'sqlalchemy==1.1.9',
|
||||
'sqlalchemy-utils==0.32.16',
|
||||
'sqlparse==0.2.3',
|
||||
'python-dateutil==2.6.1',
|
||||
'pyyaml>=3.11',
|
||||
'requests==2.18.4',
|
||||
'simplejson==3.13.2',
|
||||
'six==1.11.0',
|
||||
'sqlalchemy==1.2.2',
|
||||
'sqlalchemy-utils==0.32.21',
|
||||
'sqlparse==0.2.4',
|
||||
'thrift>=0.9.3',
|
||||
'thrift-sasl>=0.2.1',
|
||||
'unidecode>=0.04.21',
|
||||
],
|
||||
extras_require={
|
||||
'cors': ['Flask-Cors>=2.0.0'],
|
||||
|
||||
@@ -4,14 +4,13 @@ from __future__ import division
|
||||
from __future__ import print_function
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask, redirect
|
||||
from flask_appbuilder import SQLA, AppBuilder, IndexView
|
||||
from flask_appbuilder import AppBuilder, IndexView, SQLA
|
||||
from flask_appbuilder.baseviews import expose
|
||||
from flask_migrate import Migrate
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
@@ -23,6 +22,9 @@ from superset import utils, config # noqa
|
||||
APP_DIR = os.path.dirname(__file__)
|
||||
CONFIG_MODULE = os.environ.get('SUPERSET_CONFIG', 'superset.config')
|
||||
|
||||
if not os.path.exists(config.DATA_DIR):
|
||||
os.makedirs(config.DATA_DIR)
|
||||
|
||||
with open(APP_DIR + '/static/assets/backendSync.json', 'r') as f:
|
||||
frontend_config = json.load(f)
|
||||
|
||||
@@ -43,7 +45,7 @@ def parse_manifest_json():
|
||||
with open(MANIFEST_FILE, 'r') as f:
|
||||
manifest = json.load(f)
|
||||
except Exception:
|
||||
print("no manifest file found at " + MANIFEST_FILE)
|
||||
pass
|
||||
|
||||
|
||||
def get_manifest_file(filename):
|
||||
@@ -67,7 +69,7 @@ for bp in conf.get('BLUEPRINTS'):
|
||||
print("Registering blueprint: '{}'".format(bp.name))
|
||||
app.register_blueprint(bp)
|
||||
except Exception as e:
|
||||
print("blueprint registration failed")
|
||||
print('blueprint registration failed')
|
||||
logging.exception(e)
|
||||
|
||||
if conf.get('SILENCE_FAB'):
|
||||
@@ -92,7 +94,7 @@ utils.pessimistic_connection_handling(db.engine)
|
||||
cache = utils.setup_cache(app, conf.get('CACHE_CONFIG'))
|
||||
tables_cache = utils.setup_cache(app, conf.get('TABLE_NAMES_CACHE_CONFIG'))
|
||||
|
||||
migrate = Migrate(app, db, directory=APP_DIR + "/migrations")
|
||||
migrate = Migrate(app, db, directory=APP_DIR + '/migrations')
|
||||
|
||||
# Logging configuration
|
||||
logging.basicConfig(format=app.config.get('LOG_FORMAT'))
|
||||
@@ -150,16 +152,23 @@ appbuilder = AppBuilder(
|
||||
db.session,
|
||||
base_template='superset/base.html',
|
||||
indexview=MyIndexView,
|
||||
security_manager_class=app.config.get("CUSTOM_SECURITY_MANAGER"))
|
||||
security_manager_class=app.config.get('CUSTOM_SECURITY_MANAGER'),
|
||||
update_perms=utils.get_update_perms_flag(),
|
||||
)
|
||||
|
||||
sm = appbuilder.sm
|
||||
|
||||
get_session = appbuilder.get_session
|
||||
results_backend = app.config.get("RESULTS_BACKEND")
|
||||
results_backend = app.config.get('RESULTS_BACKEND')
|
||||
|
||||
# Registering sources
|
||||
module_datasource_map = app.config.get("DEFAULT_MODULE_DS_MAP")
|
||||
module_datasource_map.update(app.config.get("ADDITIONAL_MODULE_DS_MAP"))
|
||||
module_datasource_map = app.config.get('DEFAULT_MODULE_DS_MAP')
|
||||
module_datasource_map.update(app.config.get('ADDITIONAL_MODULE_DS_MAP'))
|
||||
ConnectorRegistry.register_sources(module_datasource_map)
|
||||
|
||||
# Hook that provides administrators a handle on the Flask APP
|
||||
# after initialization
|
||||
flask_app_mutator = app.config.get('FLASK_APP_MUTATOR')
|
||||
if flask_app_mutator:
|
||||
flask_app_mutator(app)
|
||||
|
||||
from superset import views # noqa
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"presets" : ["airbnb", "env", "react"],
|
||||
"presets" : ["airbnb"],
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_arc.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_geojson.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_grid.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
superset/assets/images/viz_thumbnails/deck_hex.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
superset/assets/images/viz_thumbnails/deck_multi.png
Normal file
|
After Width: | Height: | Size: 968 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_path.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_polygon.png
Normal file
|
After Width: | Height: | Size: 433 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_scatter.png
Normal file
|
After Width: | Height: | Size: 777 KiB |
BIN
superset/assets/images/viz_thumbnails/deck_screengrid.png
Normal file
|
After Width: | Height: | Size: 578 KiB |
BIN
superset/assets/images/viz_thumbnails/multi.png
Normal file
|
After Width: | Height: | Size: 743 KiB |
BIN
superset/assets/images/viz_thumbnails/partition.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
superset/assets/images/viz_thumbnails/rose.png
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
superset/assets/images/viz_thumbnails/time_pivot.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@@ -20,6 +20,7 @@ export const QUERY_EDITOR_SET_SCHEMA = 'QUERY_EDITOR_SET_SCHEMA';
|
||||
export const QUERY_EDITOR_SET_TITLE = 'QUERY_EDITOR_SET_TITLE';
|
||||
export const QUERY_EDITOR_SET_AUTORUN = 'QUERY_EDITOR_SET_AUTORUN';
|
||||
export const QUERY_EDITOR_SET_SQL = 'QUERY_EDITOR_SET_SQL';
|
||||
export const QUERY_EDITOR_SET_TEMPLATE_PARAMS = 'QUERY_EDITOR_SET_TEMPLATE_PARAMS';
|
||||
export const QUERY_EDITOR_SET_SELECTED_TEXT = 'QUERY_EDITOR_SET_SELECTED_TEXT';
|
||||
export const QUERY_EDITOR_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT';
|
||||
|
||||
@@ -132,6 +133,7 @@ export function runQuery(query) {
|
||||
tab: query.tab,
|
||||
tmp_table_name: query.tempTableName,
|
||||
select_as_cta: query.ctas,
|
||||
templateParams: query.templateParams,
|
||||
};
|
||||
const sqlJsonUrl = '/superset/sql_json/' + location.search;
|
||||
$.ajax({
|
||||
@@ -153,10 +155,12 @@ export function runQuery(query) {
|
||||
msg = err.responseText;
|
||||
}
|
||||
}
|
||||
if (textStatus === 'error' && errorThrown === '') {
|
||||
msg = t('Could not connect to server');
|
||||
} else if (msg === null) {
|
||||
msg = `[${textStatus}] ${errorThrown}`;
|
||||
if (msg === null) {
|
||||
if (errorThrown) {
|
||||
msg = `[${textStatus}] ${errorThrown}`;
|
||||
} else {
|
||||
msg = t('Unknown error');
|
||||
}
|
||||
}
|
||||
if (msg.indexOf('CSRF token') > 0) {
|
||||
msg = t('Your session timed out, please refresh your page and try again.');
|
||||
@@ -246,6 +250,10 @@ export function queryEditorSetSql(queryEditor, sql) {
|
||||
return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql };
|
||||
}
|
||||
|
||||
export function queryEditorSetTemplateParams(queryEditor, templateParams) {
|
||||
return { type: QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams };
|
||||
}
|
||||
|
||||
export function queryEditorSetSelectedText(queryEditor, sql) {
|
||||
return { type: QUERY_EDITOR_SET_SELECTED_TEXT, queryEditor, sql };
|
||||
}
|
||||
|
||||
@@ -144,7 +144,8 @@ export default class ResultSet extends React.PureComponent {
|
||||
}
|
||||
render() {
|
||||
const query = this.props.query;
|
||||
const height = this.props.search ? this.props.height - SEARCH_HEIGHT : this.props.height;
|
||||
const height = Math.max(0,
|
||||
(this.props.search ? this.props.height - SEARCH_HEIGHT : this.props.height));
|
||||
let sql;
|
||||
|
||||
if (this.props.showSql) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { t } from '../../locales';
|
||||
const propTypes = {
|
||||
allowAsync: PropTypes.bool.isRequired,
|
||||
dbId: PropTypes.number,
|
||||
queryState: PropTypes.string.isRequired,
|
||||
queryState: PropTypes.string,
|
||||
runQuery: PropTypes.func.isRequired,
|
||||
selectedText: PropTypes.string,
|
||||
stopQuery: PropTypes.func.isRequired,
|
||||
@@ -19,7 +19,6 @@ export default function RunQueryActionButton(props) {
|
||||
const runBtnText = props.selectedText ? t('Run Selected Query') : t('Run Query');
|
||||
const btnStyle = props.selectedText ? 'warning' : 'primary';
|
||||
const shouldShowStopBtn = ['running', 'pending'].indexOf(props.queryState) > -1;
|
||||
const asyncToolTip = t('Run query asynchronously');
|
||||
|
||||
const commonBtnProps = {
|
||||
bsSize: 'small',
|
||||
@@ -32,7 +31,7 @@ export default function RunQueryActionButton(props) {
|
||||
{...commonBtnProps}
|
||||
onClick={() => props.runQuery(false)}
|
||||
key="run-btn"
|
||||
tooltip={asyncToolTip}
|
||||
tooltip={t('Run query synchronously')}
|
||||
>
|
||||
<i className="fa fa-refresh" /> {runBtnText}
|
||||
</Button>
|
||||
@@ -43,7 +42,7 @@ export default function RunQueryActionButton(props) {
|
||||
{...commonBtnProps}
|
||||
onClick={() => props.runQuery(true)}
|
||||
key="run-async-btn"
|
||||
tooltip={asyncToolTip}
|
||||
tooltip={t('Run query asynchronously')}
|
||||
>
|
||||
<i className="fa fa-table" /> {runBtnText}
|
||||
</Button>
|
||||
@@ -66,12 +65,7 @@ export default function RunQueryActionButton(props) {
|
||||
} else {
|
||||
button = syncBtn;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline m-r-5 pull-left">
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
return button;
|
||||
}
|
||||
|
||||
RunQueryActionButton.propTypes = propTypes;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormControl, FormGroup, Overlay, Popover, Row, Col } from 'react-bootstrap';
|
||||
import { FormControl, FormGroup, Row, Col } from 'react-bootstrap';
|
||||
|
||||
import Button from '../../components/Button';
|
||||
import ModalTrigger from '../../components/ModalTrigger';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const propTypes = {
|
||||
@@ -41,10 +43,10 @@ class SaveQuery extends React.PureComponent {
|
||||
sql: this.props.sql,
|
||||
};
|
||||
this.props.onSave(query);
|
||||
this.setState({ showSave: false });
|
||||
this.saveModal.close();
|
||||
}
|
||||
onCancel() {
|
||||
this.setState({ showSave: false });
|
||||
this.saveModal.close();
|
||||
}
|
||||
onLabelChange(e) {
|
||||
this.setState({ label: e.target.value });
|
||||
@@ -55,73 +57,70 @@ class SaveQuery extends React.PureComponent {
|
||||
toggleSave(e) {
|
||||
this.setState({ target: e.target, showSave: !this.state.showSave });
|
||||
}
|
||||
renderPopover() {
|
||||
renderModalBody() {
|
||||
return (
|
||||
<Popover id="embed-code-popover">
|
||||
<FormGroup bsSize="small" style={{ width: '350px' }}>
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">
|
||||
{t('Label')}
|
||||
</label>
|
||||
</small>
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder={t('Label for your query')}
|
||||
value={this.state.label}
|
||||
onChange={this.onLabelChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">{t('Description')}</label>
|
||||
</small>
|
||||
<FormControl
|
||||
componentClass="textarea"
|
||||
placeholder={t('Write a description for your query')}
|
||||
value={this.state.description}
|
||||
onChange={this.onDescriptionChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<Button
|
||||
bsStyle="primary"
|
||||
onClick={this.onSave}
|
||||
className="m-r-3"
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
<Button onClick={this.onCancel} className="cancelQuery">
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</FormGroup>
|
||||
</Popover>
|
||||
<FormGroup bsSize="small">
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">
|
||||
{t('Label')}
|
||||
</label>
|
||||
</small>
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder={t('Label for your query')}
|
||||
value={this.state.label}
|
||||
onChange={this.onLabelChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<small>
|
||||
<label className="control-label" htmlFor="embed-height">{t('Description')}</label>
|
||||
</small>
|
||||
<FormControl
|
||||
componentClass="textarea"
|
||||
placeholder={t('Write a description for your query')}
|
||||
value={this.state.description}
|
||||
onChange={this.onDescriptionChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Col md={12}>
|
||||
<Button
|
||||
bsStyle="primary"
|
||||
onClick={this.onSave}
|
||||
className="m-r-3"
|
||||
>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
<Button onClick={this.onCancel} className="cancelQuery">
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<span className="SaveQuery">
|
||||
<Overlay
|
||||
trigger="click"
|
||||
target={this.state.target}
|
||||
show={this.state.showSave}
|
||||
placement="bottom"
|
||||
animation={this.props.animation}
|
||||
>
|
||||
{this.renderPopover()}
|
||||
</Overlay>
|
||||
<Button bsSize="small" className="toggleSave" onClick={this.toggleSave}>
|
||||
<i className="fa fa-save" /> {t('Save Query')}
|
||||
</Button>
|
||||
<ModalTrigger
|
||||
ref={(ref) => { this.saveModal = ref; }}
|
||||
modalTitle={t('Save Query')}
|
||||
modalBody={this.renderModalBody()}
|
||||
triggerNode={
|
||||
<Button bsSize="small" className="toggleSave" onClick={this.toggleSave}>
|
||||
<i className="fa fa-save" /> {t('Save Query')}
|
||||
</Button>
|
||||
}
|
||||
bsSize="small"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import throttle from 'lodash.throttle';
|
||||
import {
|
||||
Col,
|
||||
FormGroup,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import SplitPane from 'react-split-pane';
|
||||
|
||||
import Button from '../../components/Button';
|
||||
import TemplateParamsEditor from './TemplateParamsEditor';
|
||||
import SouthPane from './SouthPane';
|
||||
import SaveQuery from './SaveQuery';
|
||||
import Timer from '../../components/Timer';
|
||||
@@ -24,6 +26,7 @@ import { STATE_BSSTYLE_MAP } from '../constants';
|
||||
import RunQueryActionButton from './RunQueryActionButton';
|
||||
import { t } from '../../locales';
|
||||
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
@@ -50,6 +53,9 @@ class SqlEditor extends React.PureComponent {
|
||||
autorun: props.queryEditor.autorun,
|
||||
ctas: '',
|
||||
};
|
||||
|
||||
this.onResize = this.onResize.bind(this);
|
||||
this.throttledResize = throttle(this.onResize, 250);
|
||||
}
|
||||
componentWillMount() {
|
||||
if (this.state.autorun) {
|
||||
@@ -60,12 +66,16 @@ class SqlEditor extends React.PureComponent {
|
||||
}
|
||||
componentDidMount() {
|
||||
this.onResize();
|
||||
window.addEventListener('resize', this.throttledResize);
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.throttledResize);
|
||||
}
|
||||
onResize() {
|
||||
const height = this.sqlEditorHeight();
|
||||
this.setState({
|
||||
editorPaneHeight: this.refs.ace.clientHeight,
|
||||
southPaneHeight: height - this.refs.ace.clientHeight,
|
||||
editorPaneHeight: this.props.queryEditor.height,
|
||||
southPaneHeight: height - this.props.queryEditor.height,
|
||||
height,
|
||||
});
|
||||
|
||||
@@ -95,6 +105,7 @@ class SqlEditor extends React.PureComponent {
|
||||
tab: qe.title,
|
||||
schema: qe.schema,
|
||||
tempTableName: ctas ? this.state.ctas : '',
|
||||
templateParams: qe.templateParams,
|
||||
runAsync,
|
||||
ctas,
|
||||
};
|
||||
@@ -165,25 +176,37 @@ class SqlEditor extends React.PureComponent {
|
||||
<div className="sql-toolbar clearfix" id="js-sql-toolbar">
|
||||
<div className="pull-left">
|
||||
<Form inline>
|
||||
<RunQueryActionButton
|
||||
allowAsync={this.props.database ? this.props.database.allow_run_async : false}
|
||||
dbId={qe.dbId}
|
||||
queryState={this.props.latestQuery && this.props.latestQuery.state}
|
||||
runQuery={this.runQuery.bind(this)}
|
||||
selectedText={qe.selectedText}
|
||||
stopQuery={this.stopQuery.bind(this)}
|
||||
/>
|
||||
<SaveQuery
|
||||
defaultLabel={qe.title}
|
||||
sql={qe.sql}
|
||||
onSave={this.props.actions.saveQuery}
|
||||
schema={qe.schema}
|
||||
dbId={qe.dbId}
|
||||
/>
|
||||
<span className="m-r-5">
|
||||
<RunQueryActionButton
|
||||
allowAsync={this.props.database ? this.props.database.allow_run_async : false}
|
||||
dbId={qe.dbId}
|
||||
queryState={this.props.latestQuery && this.props.latestQuery.state}
|
||||
runQuery={this.runQuery.bind(this)}
|
||||
selectedText={qe.selectedText}
|
||||
stopQuery={this.stopQuery.bind(this)}
|
||||
/>
|
||||
</span>
|
||||
<span className="m-r-5">
|
||||
<SaveQuery
|
||||
defaultLabel={qe.title}
|
||||
sql={qe.sql}
|
||||
className="m-r-5"
|
||||
onSave={this.props.actions.saveQuery}
|
||||
schema={qe.schema}
|
||||
dbId={qe.dbId}
|
||||
/>
|
||||
</span>
|
||||
{ctasControls}
|
||||
</Form>
|
||||
</div>
|
||||
<div className="pull-right">
|
||||
<TemplateParamsEditor
|
||||
language="json"
|
||||
onChange={(params) => {
|
||||
this.props.actions.queryEditorSetTemplateParams(qe, params);
|
||||
}}
|
||||
code={qe.templateParams}
|
||||
/>
|
||||
{limitWarning}
|
||||
{this.props.latestQuery &&
|
||||
<Timer
|
||||
@@ -237,7 +260,7 @@ class SqlEditor extends React.PureComponent {
|
||||
split="horizontal"
|
||||
defaultSize={defaultNorthHeight}
|
||||
minSize={100}
|
||||
onChange={this.onResize.bind(this)}
|
||||
onChange={this.onResize}
|
||||
>
|
||||
<div ref="ace" style={{ width: '100%' }}>
|
||||
<div>
|
||||
@@ -258,7 +281,7 @@ class SqlEditor extends React.PureComponent {
|
||||
editorQueries={this.props.editorQueries}
|
||||
dataPreviewQueries={this.props.dataPreviewQueries}
|
||||
actions={this.props.actions}
|
||||
height={this.state.southPaneHeight}
|
||||
height={this.state.southPaneHeight || 0}
|
||||
/>
|
||||
</div>
|
||||
</SplitPane>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Badge } from 'react-bootstrap';
|
||||
|
||||
import AceEditor from 'react-ace';
|
||||
import 'brace/mode/sql';
|
||||
import 'brace/mode/json';
|
||||
import 'brace/mode/html';
|
||||
import 'brace/mode/markdown';
|
||||
import 'brace/theme/textmate';
|
||||
|
||||
import ModalTrigger from '../../components/ModalTrigger';
|
||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
||||
import Button from '../../components/Button';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
code: PropTypes.string,
|
||||
language: PropTypes.oneOf(['yaml', 'json']),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
label: null,
|
||||
description: null,
|
||||
onChange: () => {},
|
||||
code: '{}',
|
||||
};
|
||||
|
||||
export default class TemplateParamsEditor extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const codeText = props.code || '{}';
|
||||
this.state = {
|
||||
codeText,
|
||||
parsedJSON: null,
|
||||
isValid: true,
|
||||
};
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
componentDidMount() {
|
||||
this.onChange(this.state.codeText);
|
||||
}
|
||||
onChange(value) {
|
||||
const codeText = value;
|
||||
let isValid;
|
||||
let parsedJSON = {};
|
||||
try {
|
||||
parsedJSON = JSON.parse(value);
|
||||
isValid = true;
|
||||
} catch (e) {
|
||||
isValid = false;
|
||||
}
|
||||
this.setState({ parsedJSON, isValid, codeText });
|
||||
if (isValid) {
|
||||
this.props.onChange(codeText);
|
||||
} else {
|
||||
this.props.onChange('{}');
|
||||
}
|
||||
}
|
||||
renderDoc() {
|
||||
return (
|
||||
<p>
|
||||
Assign a set of parameters as <code>JSON</code> below
|
||||
(example: <code>{'{"my_table": "foo"}'}</code>),
|
||||
and they become available
|
||||
in your SQL (example: <code>SELECT * FROM {'{{ my_table }}'} </code>)
|
||||
by using
|
||||
<a
|
||||
href="http://superset.apache.org/sqllab.html#templating-with-jinja"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Jinja templating
|
||||
</a> syntax.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
renderModalBody() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderDoc()}
|
||||
<AceEditor
|
||||
mode={this.props.language}
|
||||
theme="textmate"
|
||||
style={{ border: '1px solid #CCC' }}
|
||||
minLines={25}
|
||||
maxLines={50}
|
||||
onChange={this.onChange}
|
||||
width="100%"
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
enableLiveAutocompletion
|
||||
value={this.state.codeText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const paramCount = this.state.parsedJSON ? Object.keys(this.state.parsedJSON).length : 0;
|
||||
return (
|
||||
<ModalTrigger
|
||||
modalTitle={t('Template Parameters')}
|
||||
triggerNode={
|
||||
<Button
|
||||
className="m-r-5"
|
||||
tooltip={t('Edit template parameters')}
|
||||
>
|
||||
{`${t('parameters')} `}
|
||||
{paramCount > 0 &&
|
||||
<Badge>{paramCount}</Badge>
|
||||
}
|
||||
{!this.state.isValid &&
|
||||
<InfoTooltipWithTrigger
|
||||
icon="exclamation-triangle"
|
||||
bsStyle="danger"
|
||||
tooltip={t('Invalid JSON')}
|
||||
label="invalid-json"
|
||||
/>
|
||||
}
|
||||
</Button>
|
||||
}
|
||||
modalBody={this.renderModalBody(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TemplateParamsEditor.propTypes = propTypes;
|
||||
TemplateParamsEditor.defaultProps = defaultProps;
|
||||
@@ -151,11 +151,12 @@ class VisualizeModal extends React.PureComponent {
|
||||
}
|
||||
visualize() {
|
||||
this.props.actions.createDatasource(this.buildVizOptions(), this)
|
||||
.done(() => {
|
||||
.done((resp) => {
|
||||
const columns = Object.keys(this.state.columns).map(k => this.state.columns[k]);
|
||||
const data = JSON.parse(resp);
|
||||
const mainGroupBy = columns.filter(d => d.is_dim)[0];
|
||||
const formData = {
|
||||
datasource: this.props.datasource,
|
||||
datasource: `${data.table_id}__table`,
|
||||
viz_type: this.state.chartType.value,
|
||||
since: '100 years ago',
|
||||
limit: '0',
|
||||
@@ -293,7 +294,7 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
datasource: state.datasource,
|
||||
errorMessage: state.errorMessage,
|
||||
timeout: state.common ? state.common.SUPERSET_WEBSERVER_TIMEOUT : null,
|
||||
timeout: state.common ? state.common.conf.SUPERSET_WEBSERVER_TIMEOUT : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -289,8 +289,14 @@ a.Link {
|
||||
.tooltip-inner {
|
||||
max-width: 500px;
|
||||
}
|
||||
.SplitPane.horizontal {
|
||||
padding-right: 4px;
|
||||
}
|
||||
.SouthPane {
|
||||
margin-top: 10px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: scroll;
|
||||
}
|
||||
.search-date-filter-container {
|
||||
display: flex;
|
||||
|
||||
@@ -211,6 +211,9 @@ export const sqlLabReducer = function (state, action) {
|
||||
[actions.QUERY_EDITOR_SET_SQL]() {
|
||||
return alterInArr(state, 'queryEditors', action.queryEditor, { sql: action.sql });
|
||||
},
|
||||
[actions.QUERY_EDITOR_SET_TEMPLATE_PARAMS]() {
|
||||
return alterInArr(state, 'queryEditors', action.queryEditor, { templateParams: action.templateParams });
|
||||
},
|
||||
[actions.QUERY_EDITOR_SET_SELECTED_TEXT]() {
|
||||
return alterInArr(state, 'queryEditors', action.queryEditor, { selectedText: action.sql });
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { Button, Panel, Grid, Row, Col } from 'react-bootstrap';
|
||||
import Select from 'react-virtualized-select';
|
||||
import visTypes from '../explore/stores/visTypes';
|
||||
import { t } from '../locales';
|
||||
|
||||
const propTypes = {
|
||||
datasources: PropTypes.arrayOf(PropTypes.shape({
|
||||
@@ -50,30 +51,30 @@ export default class AddSliceContainer extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<Panel header={<h3>{('Create a new slice')}</h3>}>
|
||||
<Panel header={<h3>{t('Create a new slice')}</h3>}>
|
||||
<Grid>
|
||||
<Row>
|
||||
<Col xs={12} sm={6}>
|
||||
<div>
|
||||
<p>{('Choose a datasource')}</p>
|
||||
<p>{t('Choose a datasource')}</p>
|
||||
<Select
|
||||
clearable={false}
|
||||
name="select-datasource"
|
||||
onChange={this.changeDatasource.bind(this)}
|
||||
options={this.props.datasources}
|
||||
placeholder={('Choose a datasource')}
|
||||
placeholder={t('Choose a datasource')}
|
||||
value={this.state.datasourceValue}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<p>{('Choose a visualization type')}</p>
|
||||
<p>{t('Choose a visualization type')}</p>
|
||||
<Select
|
||||
clearable={false}
|
||||
name="select-vis-type"
|
||||
onChange={this.changeVisType.bind(this)}
|
||||
options={this.vizTypeOptions}
|
||||
placeholder={('Choose a visualization type')}
|
||||
placeholder={t('Choose a visualization type')}
|
||||
value={this.state.visType}
|
||||
/>
|
||||
</div>
|
||||
@@ -83,7 +84,7 @@ export default class AddSliceContainer extends React.PureComponent {
|
||||
disabled={this.isBtnDisabled()}
|
||||
onClick={this.gotoSlice.bind(this)}
|
||||
>
|
||||
{('Create new slice')}
|
||||
{t('Create new slice')}
|
||||
</Button>
|
||||
<br /><br />
|
||||
</Col>
|
||||
|
||||
229
superset/assets/javascripts/chart/Chart.jsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Mustache from 'mustache';
|
||||
import { Tooltip } from 'react-bootstrap';
|
||||
|
||||
import { d3format } from '../modules/utils';
|
||||
import ChartBody from './ChartBody';
|
||||
import Loading from '../components/Loading';
|
||||
import { Logger, LOG_ACTIONS_RENDER_EVENT } from '../logger';
|
||||
import StackTraceMessage from '../components/StackTraceMessage';
|
||||
import visMap from '../../visualizations/main';
|
||||
import sandboxedEval from '../modules/sandbox';
|
||||
import './chart.css';
|
||||
|
||||
const propTypes = {
|
||||
annotationData: PropTypes.object,
|
||||
actions: PropTypes.object,
|
||||
chartKey: PropTypes.string.isRequired,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
datasource: PropTypes.object.isRequired,
|
||||
formData: PropTypes.object.isRequired,
|
||||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
setControlValue: PropTypes.func,
|
||||
timeout: PropTypes.number,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
// state
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryRequest: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
lastRendered: PropTypes.number,
|
||||
triggerQuery: PropTypes.bool,
|
||||
// dashboard callbacks
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
};
|
||||
|
||||
class Chart extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
// these properties are used by visualizations
|
||||
this.annotationData = props.annotationData;
|
||||
this.containerId = props.containerId;
|
||||
this.selector = `#${this.containerId}`;
|
||||
this.formData = props.formData;
|
||||
this.datasource = props.datasource;
|
||||
this.addFilter = this.addFilter.bind(this);
|
||||
this.getFilters = this.getFilters.bind(this);
|
||||
this.clearFilter = this.clearFilter.bind(this);
|
||||
this.removeFilter = this.removeFilter.bind(this);
|
||||
this.height = this.height.bind(this);
|
||||
this.width = this.width.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.triggerQuery) {
|
||||
this.props.actions.runQuery(this.props.formData, false,
|
||||
this.props.timeout,
|
||||
this.props.chartKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.annotationData = nextProps.annotationData;
|
||||
this.containerId = nextProps.containerId;
|
||||
this.selector = `#${this.containerId}`;
|
||||
this.formData = nextProps.formData;
|
||||
this.datasource = nextProps.datasource;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.queryResponse &&
|
||||
['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
|
||||
!this.props.queryResponse.error && (
|
||||
prevProps.annotationData !== this.props.annotationData ||
|
||||
prevProps.queryResponse !== this.props.queryResponse ||
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.width !== this.props.width ||
|
||||
prevProps.lastRendered !== this.props.lastRendered)
|
||||
) {
|
||||
this.renderViz();
|
||||
}
|
||||
}
|
||||
|
||||
getFilters() {
|
||||
return this.props.getFilters();
|
||||
}
|
||||
|
||||
setTooltip(tooltip) {
|
||||
this.setState({ tooltip });
|
||||
}
|
||||
|
||||
addFilter(col, vals, merge = true, refresh = true) {
|
||||
this.props.addFilter(col, vals, merge, refresh);
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.props.clearFilter();
|
||||
}
|
||||
|
||||
removeFilter(col, vals, refresh = true) {
|
||||
this.props.removeFilter(col, vals, refresh);
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.setState({
|
||||
errorMsg: null,
|
||||
});
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.width || this.container.el.offsetWidth;
|
||||
}
|
||||
|
||||
height() {
|
||||
return this.props.height || this.container.el.offsetHeight;
|
||||
}
|
||||
|
||||
d3format(col, number) {
|
||||
const { datasource } = this.props;
|
||||
const format = (datasource.column_formats && datasource.column_formats[col]) || '0.3s';
|
||||
|
||||
return d3format(format, number);
|
||||
}
|
||||
|
||||
render_template(s) {
|
||||
const context = {
|
||||
width: this.width(),
|
||||
height: this.height(),
|
||||
};
|
||||
return Mustache.render(s, context);
|
||||
}
|
||||
|
||||
renderTooltip() {
|
||||
if (this.state.tooltip) {
|
||||
/* eslint-disable react/no-danger */
|
||||
return (
|
||||
<Tooltip
|
||||
className="chart-tooltip"
|
||||
id="chart-tooltip"
|
||||
placement="right"
|
||||
positionTop={this.state.tooltip.y - 10}
|
||||
positionLeft={this.state.tooltip.x + 30}
|
||||
arrowOffsetTop={10}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
|
||||
</Tooltip>
|
||||
);
|
||||
/* eslint-enable react/no-danger */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
const viz = visMap[this.props.vizType];
|
||||
const fd = this.props.formData;
|
||||
const qr = this.props.queryResponse;
|
||||
const renderStart = Logger.getTimestamp();
|
||||
try {
|
||||
// Executing user-defined data mutator function
|
||||
if (fd.js_data) {
|
||||
qr.data = sandboxedEval(fd.js_data)(qr.data);
|
||||
}
|
||||
// [re]rendering the visualization
|
||||
viz(this, qr, this.props.setControlValue);
|
||||
Logger.append(LOG_ACTIONS_RENDER_EVENT, {
|
||||
label: this.props.chartKey,
|
||||
vis_type: this.props.vizType,
|
||||
start_offset: renderStart,
|
||||
duration: Logger.getTimestamp() - renderStart,
|
||||
});
|
||||
this.props.actions.chartRenderingSucceeded(this.props.chartKey);
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isLoading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
<div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
|
||||
{this.renderTooltip()}
|
||||
{isLoading &&
|
||||
<Loading size={25} />
|
||||
}
|
||||
{this.props.chartAlert &&
|
||||
<StackTraceMessage
|
||||
message={this.props.chartAlert}
|
||||
queryResponse={this.props.queryResponse}
|
||||
/>
|
||||
}
|
||||
|
||||
{!isLoading && !this.props.chartAlert &&
|
||||
<ChartBody
|
||||
containerId={this.containerId}
|
||||
vizType={this.props.vizType}
|
||||
height={this.height}
|
||||
width={this.width}
|
||||
ref={(inner) => {
|
||||
this.container = inner;
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Chart.propTypes = propTypes;
|
||||
Chart.defaultProps = defaultProps;
|
||||
|
||||
export default Chart;
|
||||
54
superset/assets/javascripts/chart/ChartBody.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import $ from 'jquery';
|
||||
|
||||
const propTypes = {
|
||||
containerId: PropTypes.string.isRequired,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
height: PropTypes.func.isRequired,
|
||||
width: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
class ChartBody extends React.PureComponent {
|
||||
html(data) {
|
||||
this.el.innerHTML = data;
|
||||
}
|
||||
|
||||
css(property, value) {
|
||||
this.el.style[property] = value;
|
||||
}
|
||||
|
||||
get(n) {
|
||||
return $(this.el).get(n);
|
||||
}
|
||||
|
||||
find(classname) {
|
||||
return $(this.el).find(classname);
|
||||
}
|
||||
|
||||
show() {
|
||||
return $(this.el).show();
|
||||
}
|
||||
|
||||
height() {
|
||||
return this.props.height();
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.width();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
id={this.props.containerId}
|
||||
className={`slice_container ${this.props.vizType}`}
|
||||
ref={(el) => { this.el = el; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartBody.propTypes = propTypes;
|
||||
|
||||
export default ChartBody;
|
||||
29
superset/assets/javascripts/chart/ChartContainer.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import * as Actions from './chartAction';
|
||||
import Chart from './Chart';
|
||||
|
||||
function mapStateToProps({ charts }, ownProps) {
|
||||
const chart = charts[ownProps.chartKey];
|
||||
return {
|
||||
annotationData: chart.annotationData,
|
||||
chartAlert: chart.chartAlert,
|
||||
chartStatus: chart.chartStatus,
|
||||
chartUpdateEndTime: chart.chartUpdateEndTime,
|
||||
chartUpdateStartTime: chart.chartUpdateStartTime,
|
||||
latestQueryFormData: chart.latestQueryFormData,
|
||||
lastRendered: chart.lastRendered,
|
||||
queryResponse: chart.queryResponse,
|
||||
queryRequest: chart.queryRequest,
|
||||
triggerQuery: chart.triggerQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(Actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Chart);
|
||||
4
superset/assets/javascripts/chart/chart.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.chart-tooltip {
|
||||
opacity: 0.75;
|
||||
font-size: 12px;
|
||||
}
|
||||
172
superset/assets/javascripts/chart/chartAction.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { getExploreUrl, getAnnotationJsonUrl } from '../explore/exploreUtils';
|
||||
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
|
||||
import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted(queryRequest, key) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(key) {
|
||||
return { type: CHART_UPDATE_STOPPED, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
|
||||
export function chartUpdateTimeout(statusText, timeout, key) {
|
||||
return { type: CHART_UPDATE_TIMEOUT, statusText, timeout, key };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse, key) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse, key };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
||||
export function chartRenderingFailed(error, key) {
|
||||
return { type: CHART_RENDERING_FAILED, error, key };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_SUCCEEDED = 'CHART_RENDERING_SUCCEEDED';
|
||||
export function chartRenderingSucceeded(key) {
|
||||
return { type: CHART_RENDERING_SUCCEEDED, key };
|
||||
}
|
||||
|
||||
export const REMOVE_CHART = 'REMOVE_CHART';
|
||||
export function removeChart(key) {
|
||||
return { type: REMOVE_CHART, key };
|
||||
}
|
||||
|
||||
export const ANNOTATION_QUERY_SUCCESS = 'ANNOTATION_QUERY_SUCCESS';
|
||||
export function annotationQuerySuccess(annotation, queryResponse, key) {
|
||||
return { type: ANNOTATION_QUERY_SUCCESS, annotation, queryResponse, key };
|
||||
}
|
||||
|
||||
export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED';
|
||||
export function annotationQueryStarted(annotation, queryRequest, key) {
|
||||
return { type: ANNOTATION_QUERY_STARTED, annotation, queryRequest, key };
|
||||
}
|
||||
|
||||
export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED';
|
||||
export function annotationQueryFailed(annotation, queryResponse, key) {
|
||||
return { type: ANNOTATION_QUERY_FAILED, annotation, queryResponse, key };
|
||||
}
|
||||
|
||||
export function runAnnotationQuery(annotation, timeout = 60, formData = null, key) {
|
||||
return function (dispatch, getState) {
|
||||
const sliceKey = key || Object.keys(getState().charts)[0];
|
||||
const fd = formData || getState().charts[sliceKey].latestQueryFormData;
|
||||
|
||||
if (!requiresQuery(annotation.sourceType)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const sliceFormData = Object.keys(annotation.overrides)
|
||||
.reduce((d, k) => ({
|
||||
...d,
|
||||
[k]: annotation.overrides[k] || fd[k],
|
||||
}), {});
|
||||
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
|
||||
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
timeout: timeout * 1000,
|
||||
});
|
||||
dispatch(annotationQueryStarted(annotation, queryRequest, sliceKey));
|
||||
return queryRequest
|
||||
.then(queryResponse => dispatch(annotationQuerySuccess(annotation, queryResponse, sliceKey)))
|
||||
.catch((err) => {
|
||||
if (err.statusText === 'timeout') {
|
||||
dispatch(annotationQueryFailed(annotation, { error: 'Query Timeout' }, sliceKey));
|
||||
} else if ((err.responseJSON.error || '').toLowerCase().startsWith('no data')) {
|
||||
dispatch(annotationQuerySuccess(annotation, err, sliceKey));
|
||||
} else if (err.statusText !== 'abort') {
|
||||
dispatch(annotationQueryFailed(annotation, err.responseJSON, sliceKey));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
||||
export function triggerQuery(value = true, key) {
|
||||
return { type: TRIGGER_QUERY, value, key };
|
||||
}
|
||||
|
||||
// this action is used for forced re-render without fetch data
|
||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
||||
export function renderTriggered(value, key) {
|
||||
return { type: RENDER_TRIGGERED, value, key };
|
||||
}
|
||||
|
||||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, force = false, timeout = 60, key) {
|
||||
return (dispatch) => {
|
||||
const url = getExploreUrl(formData, 'json', force);
|
||||
let logStart;
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
timeout: timeout * 1000,
|
||||
beforeSend: () => {
|
||||
logStart = Logger.getTimestamp();
|
||||
},
|
||||
});
|
||||
|
||||
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, key)))
|
||||
.then(() => queryRequest)
|
||||
.then((queryResponse) => {
|
||||
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
|
||||
label: key,
|
||||
is_cached: queryResponse.is_cached,
|
||||
row_count: queryResponse.rowcount,
|
||||
datasource: formData.datasource,
|
||||
start_offset: logStart,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
});
|
||||
return dispatch(chartUpdateSucceeded(queryResponse, key));
|
||||
})
|
||||
.catch((err) => {
|
||||
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
|
||||
label: key,
|
||||
has_err: true,
|
||||
datasource: formData.datasource,
|
||||
start_offset: logStart,
|
||||
duration: Logger.getTimestamp() - logStart,
|
||||
});
|
||||
if (err.statusText === 'timeout') {
|
||||
dispatch(chartUpdateTimeout(err.statusText, timeout, key));
|
||||
} else if (err.statusText === 'abort') {
|
||||
dispatch(chartUpdateStopped(key));
|
||||
} else {
|
||||
let errObject;
|
||||
if (err.responseJSON) {
|
||||
errObject = err.responseJSON;
|
||||
} else if (err.stack) {
|
||||
errObject = {
|
||||
error: 'Unexpected error: ' + err.description,
|
||||
stacktrace: err.stack,
|
||||
};
|
||||
} else {
|
||||
errObject = {
|
||||
error: 'Unexpected error.',
|
||||
};
|
||||
}
|
||||
dispatch(chartUpdateFailed(errObject, key));
|
||||
}
|
||||
});
|
||||
const annotationLayers = formData.annotation_layers || [];
|
||||
return Promise.all([
|
||||
queryPromise,
|
||||
dispatch(triggerQuery(false, key)),
|
||||
...annotationLayers.map(x => dispatch(runAnnotationQuery(x, timeout, formData, key))),
|
||||
]);
|
||||
};
|
||||
}
|
||||
155
superset/assets/javascripts/chart/chartReducer.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { now } from '../modules/dates';
|
||||
import * as actions from './chartAction';
|
||||
import { t } from '../locales';
|
||||
|
||||
export const chartPropType = {
|
||||
chartKey: PropTypes.string.isRequired,
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryRequest: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
triggerQuery: PropTypes.bool,
|
||||
lastRendered: PropTypes.number,
|
||||
};
|
||||
|
||||
export const chart = {
|
||||
chartKey: '',
|
||||
chartAlert: null,
|
||||
chartStatus: 'loading',
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
latestQueryFormData: null,
|
||||
queryRequest: null,
|
||||
queryResponse: null,
|
||||
triggerQuery: true,
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
export default function chartReducer(charts = {}, action) {
|
||||
const actionHandlers = {
|
||||
[actions.CHART_UPDATE_SUCCEEDED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'success',
|
||||
queryResponse: action.queryResponse,
|
||||
chartUpdateEndTime: now(),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_STARTED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'loading',
|
||||
chartAlert: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: now(),
|
||||
queryRequest: action.queryRequest,
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_STOPPED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'stopped',
|
||||
chartAlert: t('Updating chart was stopped'),
|
||||
};
|
||||
},
|
||||
[actions.CHART_RENDERING_SUCCEEDED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'rendered',
|
||||
};
|
||||
},
|
||||
[actions.CHART_RENDERING_FAILED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: t('An error occurred while rendering the visualization: %s', action.error),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_TIMEOUT](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: (
|
||||
`<strong>${t('Query timeout')}</strong> - ` +
|
||||
t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
|
||||
t('Perhaps your data has grown, your database is under unusual load, ' +
|
||||
'or you are simply querying a data source that is too large ' +
|
||||
'to be processed within the timeout range. ' +
|
||||
'If that is the case, we recommend that you summarize your data further.')),
|
||||
};
|
||||
},
|
||||
[actions.CHART_UPDATE_FAILED](state) {
|
||||
return { ...state,
|
||||
chartStatus: 'failed',
|
||||
chartAlert: action.queryResponse ? action.queryResponse.error : t('Network error.'),
|
||||
chartUpdateEndTime: now(),
|
||||
queryResponse: action.queryResponse,
|
||||
};
|
||||
},
|
||||
[actions.TRIGGER_QUERY](state) {
|
||||
return { ...state, triggerQuery: action.value };
|
||||
},
|
||||
[actions.RENDER_TRIGGERED](state) {
|
||||
return { ...state, lastRendered: action.value };
|
||||
},
|
||||
[actions.ANNOTATION_QUERY_STARTED](state) {
|
||||
if (state.annotationQuery &&
|
||||
state.annotationQuery[action.annotation.name]) {
|
||||
state.annotationQuery[action.annotation.name].abort();
|
||||
}
|
||||
const annotationQuery = {
|
||||
...state.annotationQuery,
|
||||
[action.annotation.name]: action.queryRequest,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
annotationQuery,
|
||||
};
|
||||
},
|
||||
[actions.ANNOTATION_QUERY_SUCCESS](state) {
|
||||
const annotationData = {
|
||||
...state.annotationData,
|
||||
[action.annotation.name]: action.queryResponse.data,
|
||||
};
|
||||
const annotationError = { ...state.annotationError };
|
||||
delete annotationError[action.annotation.name];
|
||||
const annotationQuery = { ...state.annotationQuery };
|
||||
delete annotationQuery[action.annotation.name];
|
||||
return {
|
||||
...state,
|
||||
annotationData,
|
||||
annotationError,
|
||||
annotationQuery,
|
||||
};
|
||||
},
|
||||
[actions.ANNOTATION_QUERY_FAILED](state) {
|
||||
const annotationData = { ...state.annotationData };
|
||||
delete annotationData[action.annotation.name];
|
||||
const annotationError = {
|
||||
...state.annotationError,
|
||||
[action.annotation.name]: action.queryResponse ?
|
||||
action.queryResponse.error : t('Network error.'),
|
||||
};
|
||||
const annotationQuery = { ...state.annotationQuery };
|
||||
delete annotationQuery[action.annotation.name];
|
||||
return {
|
||||
...state,
|
||||
annotationData,
|
||||
annotationError,
|
||||
annotationQuery,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
if (action.type === actions.REMOVE_CHART) {
|
||||
delete charts[action.key];
|
||||
return charts;
|
||||
}
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
|
||||
}
|
||||
|
||||
return charts;
|
||||
}
|
||||
145
superset/assets/javascripts/components/AlteredSliceTag.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Table, Tr, Td, Thead, Th } from 'reactable';
|
||||
import { isEqual, isEmpty } from 'underscore';
|
||||
|
||||
import TooltipWrapper from './TooltipWrapper';
|
||||
import { controls } from '../explore/stores/controls';
|
||||
import ModalTrigger from './ModalTrigger';
|
||||
import { t } from '../locales';
|
||||
|
||||
const propTypes = {
|
||||
origFormData: PropTypes.object.isRequired,
|
||||
currentFormData: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default class AlteredSliceTag extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const diffs = this.getDiffs(props);
|
||||
this.state = { diffs, hasDiffs: !isEmpty(diffs) };
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
// Update differences if need be
|
||||
if (isEqual(this.props, newProps)) {
|
||||
return;
|
||||
}
|
||||
const diffs = this.getDiffs(newProps);
|
||||
this.setState({ diffs, hasDiffs: !isEmpty(diffs) });
|
||||
}
|
||||
|
||||
getDiffs(props) {
|
||||
// Returns all properties that differ in the
|
||||
// current form data and the saved form data
|
||||
const ofd = props.origFormData;
|
||||
const cfd = props.currentFormData;
|
||||
const fdKeys = Object.keys(cfd);
|
||||
const diffs = {};
|
||||
for (const fdKey of fdKeys) {
|
||||
// Ignore values that are undefined/nonexisting in either
|
||||
if (!ofd[fdKey] && !cfd[fdKey]) {
|
||||
continue;
|
||||
}
|
||||
if (!isEqual(ofd[fdKey], cfd[fdKey])) {
|
||||
diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
formatValue(value, key) {
|
||||
// Format display value based on the control type
|
||||
// or the value type
|
||||
if (value === undefined) {
|
||||
return 'N/A';
|
||||
} else if (value === null) {
|
||||
return 'null';
|
||||
} else if (controls[key] && controls[key].type === 'FilterControl') {
|
||||
if (!value.length) {
|
||||
return '[]';
|
||||
}
|
||||
return value.map((v) => {
|
||||
const filterVal = v.val.constructor === Array ? `[${v.val.join(', ')}]` : v.val;
|
||||
return `${v.col} ${v.op} ${filterVal}`;
|
||||
}).join(', ');
|
||||
} else if (controls[key] && controls[key].type === 'BoundsControl') {
|
||||
return `Min: ${value[0]}, Max: ${value[1]}`;
|
||||
} else if (controls[key] && controls[key].type === 'CollectionControl') {
|
||||
return value.map(v => JSON.stringify(v)).join(', ');
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
} else if (value.constructor === Array) {
|
||||
return value.length ? value.join(', ') : '[]';
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
renderRows() {
|
||||
const diffs = this.state.diffs;
|
||||
const rows = [];
|
||||
for (const key in diffs) {
|
||||
rows.push(
|
||||
<Tr key={key}>
|
||||
<Td column="control" data={(controls[key] && controls[key].label) || key} />
|
||||
<Td column="before">{this.formatValue(diffs[key].before, key)}</Td>
|
||||
<Td column="after">{this.formatValue(diffs[key].after, key)}</Td>
|
||||
</Tr>,
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
renderModalBody() {
|
||||
return (
|
||||
<Table className="table" sortable>
|
||||
<Thead>
|
||||
<Th column="control">Control</Th>
|
||||
<Th column="before">Before</Th>
|
||||
<Th column="after">After</Th>
|
||||
</Thead>
|
||||
{this.renderRows()}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
renderTriggerNode() {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
label="difference"
|
||||
tooltip={t('Click to see difference')}
|
||||
>
|
||||
<span
|
||||
className="label label-warning m-l-5"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
{t('Altered')}
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
// Return nothing if there are no differences
|
||||
if (!this.state.hasDiffs) {
|
||||
return null;
|
||||
}
|
||||
// Render the label-warning 'Altered' tag which the user may
|
||||
// click to open a modal containing a table summarizing the
|
||||
// differences in the slice
|
||||
return (
|
||||
<ModalTrigger
|
||||
animation
|
||||
triggerNode={this.renderTriggerNode()}
|
||||
modalTitle={t('Slice changes')}
|
||||
bsSize="large"
|
||||
modalBody={this.renderModalBody()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AlteredSliceTag.propTypes = propTypes;
|
||||
@@ -42,19 +42,20 @@ class AsyncSelect extends React.PureComponent {
|
||||
fetchOptions() {
|
||||
this.setState({ isLoading: true });
|
||||
const mutator = this.props.mutator;
|
||||
$.get(this.props.dataEndpoint, (data) => {
|
||||
this.setState({ options: mutator ? mutator(data) : data, isLoading: false });
|
||||
$.get(this.props.dataEndpoint)
|
||||
.done((data) => {
|
||||
this.setState({ options: mutator ? mutator(data) : data, isLoading: false });
|
||||
|
||||
if (!this.props.value && this.props.autoSelect && this.state.options.length) {
|
||||
this.onChange(this.state.options[0]);
|
||||
}
|
||||
})
|
||||
.fail(() => {
|
||||
this.props.onAsyncError();
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
if (!this.props.value && this.props.autoSelect && this.state.options.length) {
|
||||
this.onChange(this.state.options[0]);
|
||||
}
|
||||
})
|
||||
.fail((xhr) => {
|
||||
this.props.onAsyncError(xhr.responseText);
|
||||
})
|
||||
.always(() => {
|
||||
this.setState({ isLoading: false });
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
|
||||
@@ -5,9 +5,13 @@ import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
|
||||
|
||||
const propTypes = {
|
||||
column: PropTypes.object.isRequired,
|
||||
showType: PropTypes.bool,
|
||||
};
|
||||
const defaultProps = {
|
||||
showType: false,
|
||||
};
|
||||
|
||||
export default function ColumnOption({ column }) {
|
||||
export default function ColumnOption({ column, showType }) {
|
||||
return (
|
||||
<span>
|
||||
<span className="m-r-5 option-label">
|
||||
@@ -29,6 +33,10 @@ export default function ColumnOption({ column }) {
|
||||
label={`expr-${column.column_name}`}
|
||||
/>
|
||||
}
|
||||
{showType &&
|
||||
<span className="text-muted">{column.type}</span>
|
||||
}
|
||||
</span>);
|
||||
}
|
||||
ColumnOption.propTypes = propTypes;
|
||||
ColumnOption.defaultProps = defaultProps;
|
||||
|
||||
@@ -56,14 +56,16 @@ export default class CopyToClipboard extends React.Component {
|
||||
selection.removeAllRanges();
|
||||
document.activeElement.blur();
|
||||
const range = document.createRange();
|
||||
const textArea = document.createElement('textarea');
|
||||
const span = document.createElement('span');
|
||||
span.textContent = textToCopy;
|
||||
span.style.all = 'unset';
|
||||
span.style.position = 'fixed';
|
||||
span.style.top = 0;
|
||||
span.style.clip = 'rect(0, 0, 0, 0)';
|
||||
span.style.whiteSpace = 'pre';
|
||||
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-1000px';
|
||||
textArea.value = textToCopy;
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
range.selectNode(textArea);
|
||||
document.body.appendChild(span);
|
||||
range.selectNode(span);
|
||||
selection.addRange(range);
|
||||
try {
|
||||
if (!document.execCommand('copy')) {
|
||||
@@ -73,7 +75,7 @@ export default class CopyToClipboard extends React.Component {
|
||||
window.alert(t('Sorry, your browser does not support copying. Use Ctrl / Cmd + C!')); // eslint-disable-line
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
document.body.removeChild(span);
|
||||
if (selection.removeRange) {
|
||||
selection.removeRange(range);
|
||||
} else {
|
||||
|
||||
@@ -8,10 +8,12 @@ const propTypes = {
|
||||
canEdit: PropTypes.bool,
|
||||
onSaveTitle: PropTypes.func,
|
||||
noPermitTooltip: PropTypes.string,
|
||||
showTooltip: PropTypes.bool,
|
||||
};
|
||||
const defaultProps = {
|
||||
title: t('Title'),
|
||||
canEdit: false,
|
||||
showTooltip: true,
|
||||
};
|
||||
|
||||
class EditableTitle extends React.PureComponent {
|
||||
@@ -85,24 +87,30 @@ class EditableTitle extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<span className="editable-title">
|
||||
let input = (
|
||||
<input
|
||||
required
|
||||
type={this.state.isEditing ? 'text' : 'button'}
|
||||
value={this.state.title}
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.handleClick}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
);
|
||||
if (this.props.showTooltip) {
|
||||
input = (
|
||||
<TooltipWrapper
|
||||
label="title"
|
||||
tooltip={this.props.canEdit ? t('click to edit title') :
|
||||
this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
|
||||
>
|
||||
<input
|
||||
required
|
||||
type={this.state.isEditing ? 'text' : 'button'}
|
||||
value={this.state.title}
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleBlur}
|
||||
onClick={this.handleClick}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
{input}
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="editable-title">{input}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@ import TooltipWrapper from './TooltipWrapper';
|
||||
import { t } from '../locales';
|
||||
|
||||
const propTypes = {
|
||||
sliceId: PropTypes.number.isRequired,
|
||||
actions: PropTypes.object.isRequired,
|
||||
itemId: PropTypes.number.isRequired,
|
||||
fetchFaveStar: PropTypes.func,
|
||||
saveFaveStar: PropTypes.func,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default class FaveStar extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.actions.fetchFaveStar(this.props.sliceId);
|
||||
this.props.fetchFaveStar(this.props.itemId);
|
||||
}
|
||||
|
||||
onClick(e) {
|
||||
e.preventDefault();
|
||||
this.props.actions.saveFaveStar(this.props.sliceId, this.props.isStarred);
|
||||
this.props.saveFaveStar(this.props.itemId, this.props.isStarred);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
.ReactVirtualized__Grid__innerScrollContainer {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.ReactVirtualized__Table__headerRow {
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.ReactVirtualized__Table__row {
|
||||
display: flex;
|
||||
@@ -50,11 +54,6 @@
|
||||
}
|
||||
.even-row { background: #f2f2f2; }
|
||||
.odd-row { background: #ffffff; }
|
||||
.even-row,
|
||||
.odd-row {
|
||||
border: none;
|
||||
}
|
||||
.filterable-table-container {
|
||||
overflow: auto;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const tooltipStyle = { wordWrap: 'break-word' };
|
||||
|
||||
export default function InfoTooltipWithTrigger({
|
||||
label, tooltip, icon, className, onClick, placement, bsStyle }) {
|
||||
const iconClass = `fa fa-${icon} ${className} ${bsStyle ? 'text-' + bsStyle : ''}`;
|
||||
const iconClass = `fa fa-${icon} ${className} ${bsStyle ? `text-${bsStyle}` : ''}`;
|
||||
const iconEl = (
|
||||
<i
|
||||
className={iconClass}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Loading(props) {
|
||||
height: props.size,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
position: 'absolute',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,18 +5,20 @@ import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
|
||||
|
||||
const propTypes = {
|
||||
metric: PropTypes.object.isRequired,
|
||||
openInNewWindow: PropTypes.bool,
|
||||
showFormula: PropTypes.bool,
|
||||
url: PropTypes.string,
|
||||
};
|
||||
const defaultProps = {
|
||||
showFormula: true,
|
||||
};
|
||||
|
||||
export default function MetricOption({ metric, showFormula }) {
|
||||
export default function MetricOption({ metric, openInNewWindow, showFormula, url }) {
|
||||
const verbose = metric.verbose_name || metric.metric_name;
|
||||
const link = url ? <a href={url} target={openInNewWindow ? '_blank' : null}>{verbose}</a> : verbose;
|
||||
return (
|
||||
<div>
|
||||
<span className="m-r-5 option-label">
|
||||
{metric.verbose_name || metric.metric_name}
|
||||
</span>
|
||||
<span className="m-r-5 option-label">{link}</span>
|
||||
{metric.description &&
|
||||
<InfoTooltipWithTrigger
|
||||
className="m-r-5 text-muted"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal } from 'react-bootstrap';
|
||||
import { Modal, MenuItem } from 'react-bootstrap';
|
||||
import cx from 'classnames';
|
||||
|
||||
import Button from './Button';
|
||||
|
||||
const propTypes = {
|
||||
@@ -13,6 +14,7 @@ const propTypes = {
|
||||
beforeOpen: PropTypes.func,
|
||||
onExit: PropTypes.func,
|
||||
isButton: PropTypes.bool,
|
||||
isMenuItem: PropTypes.bool,
|
||||
bsSize: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
@@ -23,6 +25,7 @@ const defaultProps = {
|
||||
beforeOpen: () => {},
|
||||
onExit: () => {},
|
||||
isButton: false,
|
||||
isMenuItem: false,
|
||||
bsSize: null,
|
||||
className: '',
|
||||
};
|
||||
@@ -86,6 +89,13 @@ export default class ModalTrigger extends React.Component {
|
||||
{this.renderModal()}
|
||||
</Button>
|
||||
);
|
||||
} else if (this.props.isMenuItem) {
|
||||
return (
|
||||
<MenuItem onClick={this.open}>
|
||||
{this.props.triggerNode}
|
||||
{this.renderModal()}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
return (
|
||||
|
||||
87
superset/assets/javascripts/components/OnPasteSelect.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Select from 'react-select';
|
||||
|
||||
export default class OnPasteSelect extends React.Component {
|
||||
onPaste(evt) {
|
||||
if (!this.props.multi) {
|
||||
return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
const clipboard = evt.clipboardData.getData('Text');
|
||||
if (!clipboard) {
|
||||
return;
|
||||
}
|
||||
const regex = `[${this.props.separator}]+`;
|
||||
const values = clipboard.split(new RegExp(regex)).map(v => v.trim());
|
||||
const validator = this.props.isValidNewOption;
|
||||
const selected = this.props.value || [];
|
||||
const existingOptions = {};
|
||||
const existing = {};
|
||||
this.props.options.forEach((v) => {
|
||||
existingOptions[v[this.props.valueKey]] = 1;
|
||||
});
|
||||
let options = [];
|
||||
selected.forEach((v) => {
|
||||
options.push({ [this.props.labelKey]: v, [this.props.valueKey]: v });
|
||||
existing[v] = 1;
|
||||
});
|
||||
options = options.concat(values
|
||||
.filter((v) => {
|
||||
const notExists = !existing[v];
|
||||
existing[v] = 1;
|
||||
return notExists && (validator ? validator({ [this.props.labelKey]: v }) : !!v);
|
||||
})
|
||||
.map((v) => {
|
||||
const opt = { [this.props.labelKey]: v, [this.props.valueKey]: v };
|
||||
if (!existingOptions[v]) {
|
||||
this.props.options.unshift(opt);
|
||||
}
|
||||
return opt;
|
||||
}),
|
||||
);
|
||||
if (options.length) {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const SelectComponent = this.props.selectWrap;
|
||||
const refFunc = (ref) => {
|
||||
if (this.props.ref) {
|
||||
this.props.ref(ref);
|
||||
}
|
||||
this.pasteInput = ref;
|
||||
};
|
||||
const inputProps = { onPaste: this.onPaste.bind(this) };
|
||||
return (
|
||||
<SelectComponent
|
||||
{...this.props}
|
||||
ref={refFunc}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OnPasteSelect.propTypes = {
|
||||
separator: PropTypes.string.isRequired,
|
||||
selectWrap: PropTypes.func.isRequired,
|
||||
ref: PropTypes.func,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
valueKey: PropTypes.string.isRequired,
|
||||
labelKey: PropTypes.string.isRequired,
|
||||
options: PropTypes.array,
|
||||
multi: PropTypes.bool.isRequired,
|
||||
value: PropTypes.any,
|
||||
isValidNewOption: PropTypes.func,
|
||||
};
|
||||
OnPasteSelect.defaultProps = {
|
||||
separator: ',',
|
||||
selectWrap: Select,
|
||||
valueKey: 'value',
|
||||
labelKey: 'label',
|
||||
options: [],
|
||||
multi: false,
|
||||
};
|
||||
28
superset/assets/javascripts/components/OptionDescription.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
|
||||
|
||||
const propTypes = {
|
||||
option: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
// This component provides a general tooltip for options
|
||||
// in a SelectControl
|
||||
export default function OptionDescription({ option }) {
|
||||
return (
|
||||
<span>
|
||||
<span className="m-r-5 option-label">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.description &&
|
||||
<InfoTooltipWithTrigger
|
||||
className="m-r-5 text-muted"
|
||||
icon="question-circle-o"
|
||||
tooltip={option.description}
|
||||
label={`descr-${option.label}`}
|
||||
/>
|
||||
}
|
||||
</span>);
|
||||
}
|
||||
OptionDescription.propTypes = propTypes;
|
||||
@@ -23,7 +23,7 @@ export default function PopoverSection({ title, isSelected, children, onSelect,
|
||||
|
||||
<i className={isSelected ? 'fa fa-check text-primary' : ''} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="m-t-5 m-l-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
52
superset/assets/javascripts/components/StackTraceMessage.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Collapse } from 'react-bootstrap';
|
||||
|
||||
const propTypes = {
|
||||
message: PropTypes.string,
|
||||
queryResponse: PropTypes.object,
|
||||
showStackTrace: PropTypes.bool,
|
||||
};
|
||||
const defaultProps = {
|
||||
showStackTrace: false,
|
||||
};
|
||||
|
||||
class StackTraceMessage extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showStackTrace: props.showStackTrace,
|
||||
};
|
||||
}
|
||||
|
||||
hasTrace() {
|
||||
return this.props.queryResponse && this.props.queryResponse.stacktrace;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={`stack-trace-container${this.hasTrace() ? ' has-trace' : ''}`}>
|
||||
<Alert
|
||||
bsStyle="warning"
|
||||
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
|
||||
>
|
||||
{this.props.message}
|
||||
</Alert>
|
||||
{this.hasTrace() &&
|
||||
<Collapse in={this.state.showStackTrace}>
|
||||
<pre>
|
||||
{this.props.queryResponse.stacktrace}
|
||||
</pre>
|
||||
</Collapse>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
StackTraceMessage.propTypes = propTypes;
|
||||
StackTraceMessage.defaultProps = defaultProps;
|
||||
|
||||
export default StackTraceMessage;
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function VirtualizedRendererWrap(renderer) {
|
||||
function WrapperRenderer({
|
||||
focusedOption,
|
||||
focusOption,
|
||||
key,
|
||||
option,
|
||||
selectValue,
|
||||
style,
|
||||
valueArray,
|
||||
}) {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const className = ['VirtualizedSelectOption'];
|
||||
if (option === focusedOption) {
|
||||
className.push('VirtualizedSelectFocusedOption');
|
||||
}
|
||||
if (option.disabled) {
|
||||
className.push('VirtualizedSelectDisabledOption');
|
||||
}
|
||||
if (valueArray && valueArray.indexOf(option) >= 0) {
|
||||
className.push('VirtualizedSelectSelectedOption');
|
||||
}
|
||||
if (option.className) {
|
||||
className.push(option.className);
|
||||
}
|
||||
const events = option.disabled ? {} : {
|
||||
onClick: () => selectValue(option),
|
||||
onMouseEnter: () => focusOption(option),
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={className.join(' ')}
|
||||
key={key}
|
||||
style={Object.assign(option.style || {}, style)}
|
||||
title={option.title}
|
||||
{...events}
|
||||
>
|
||||
{renderer(option)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
WrapperRenderer.propTypes = {
|
||||
focusedOption: PropTypes.object.isRequired,
|
||||
focusOption: PropTypes.func.isRequired,
|
||||
key: PropTypes.string,
|
||||
option: PropTypes.object,
|
||||
selectValue: PropTypes.func.isRequired,
|
||||
style: PropTypes.object,
|
||||
valueArray: PropTypes.array,
|
||||
};
|
||||
return WrapperRenderer;
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import d3 from 'd3';
|
||||
import { Alert } from 'react-bootstrap';
|
||||
import moment from 'moment';
|
||||
|
||||
import GridLayout from './components/GridLayout';
|
||||
import Header from './components/Header';
|
||||
import { appSetup } from '../common';
|
||||
import AlertsWrapper from '../components/AlertsWrapper';
|
||||
import { t } from '../locales';
|
||||
import '../../stylesheets/dashboard.css';
|
||||
|
||||
const superset = require('../modules/superset');
|
||||
const urlLib = require('url');
|
||||
const utils = require('../modules/utils');
|
||||
|
||||
let px;
|
||||
|
||||
appSetup();
|
||||
|
||||
export function getInitialState(boostrapData) {
|
||||
const dashboard = Object.assign(
|
||||
{},
|
||||
utils.controllerInterface,
|
||||
boostrapData.dashboard_data,
|
||||
{ common: boostrapData.common });
|
||||
dashboard.firstLoad = true;
|
||||
|
||||
dashboard.posDict = {};
|
||||
if (dashboard.position_json) {
|
||||
dashboard.position_json.forEach((position) => {
|
||||
dashboard.posDict[position.slice_id] = position;
|
||||
});
|
||||
}
|
||||
dashboard.refreshTimer = null;
|
||||
const state = Object.assign({}, boostrapData, { dashboard });
|
||||
return state;
|
||||
}
|
||||
|
||||
function unload() {
|
||||
const message = t('You have unsaved changes.');
|
||||
window.event.returnValue = message; // Gecko + IE
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
function onBeforeUnload(hasChanged) {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', unload);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlert() {
|
||||
render(
|
||||
<div className="container-fluid">
|
||||
<Alert bsStyle="warning">
|
||||
<strong>{t('You have unsaved changes.')}</strong> {t('Click the')}
|
||||
<i className="fa fa-save" />
|
||||
{t('button on the top right to save your changes.')}
|
||||
</Alert>
|
||||
</div>,
|
||||
document.getElementById('alert-container'),
|
||||
);
|
||||
}
|
||||
|
||||
function initDashboardView(dashboard) {
|
||||
render(
|
||||
<div>
|
||||
<AlertsWrapper initMessages={dashboard.common.flash_messages} />
|
||||
<Header dashboard={dashboard} />
|
||||
</div>,
|
||||
document.getElementById('dashboard-header'),
|
||||
);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
dashboard.reactGridLayout = render(
|
||||
<GridLayout dashboard={dashboard} />,
|
||||
document.getElementById('grid-container'),
|
||||
);
|
||||
|
||||
// Displaying widget controls on hover
|
||||
$('.react-grid-item').hover(
|
||||
function () {
|
||||
$(this).find('.chart-controls').fadeIn(300);
|
||||
},
|
||||
function () {
|
||||
$(this).find('.chart-controls').fadeOut(300);
|
||||
},
|
||||
);
|
||||
$('div.grid-container').css('visibility', 'visible');
|
||||
|
||||
$('div.widget').click(function (e) {
|
||||
const $this = $(this);
|
||||
const $target = $(e.target);
|
||||
|
||||
if ($target.hasClass('slice_info')) {
|
||||
$this.find('.slice_description').slideToggle(0, function () {
|
||||
$this.find('.refresh').click();
|
||||
});
|
||||
} else if ($target.hasClass('controls-toggle')) {
|
||||
$this.find('.chart-controls').toggle();
|
||||
}
|
||||
});
|
||||
px.initFavStars();
|
||||
$('[data-toggle="tooltip"]').tooltip({ container: 'body' });
|
||||
}
|
||||
|
||||
export function dashboardContainer(dashboard, datasources, userid) {
|
||||
return Object.assign({}, dashboard, {
|
||||
type: 'dashboard',
|
||||
filters: {},
|
||||
curUserId: userid,
|
||||
init() {
|
||||
this.sliceObjects = [];
|
||||
dashboard.slices.forEach((data) => {
|
||||
if (data.error) {
|
||||
const html = `<div class="alert alert-danger">${data.error}</div>`;
|
||||
$(`#slice_${data.slice_id}`).find('.token').html(html);
|
||||
} else {
|
||||
const slice = px.Slice(data, datasources[data.form_data.datasource], this);
|
||||
$(`#slice_${data.slice_id}`).find('a.refresh').click(() => {
|
||||
slice.render(true);
|
||||
});
|
||||
this.sliceObjects.push(slice);
|
||||
}
|
||||
});
|
||||
this.loadPreSelectFilters();
|
||||
this.renderSlices(this.sliceObjects);
|
||||
this.firstLoad = false;
|
||||
this.bindResizeToWindowResize();
|
||||
},
|
||||
onChange() {
|
||||
onBeforeUnload(true);
|
||||
renderAlert();
|
||||
},
|
||||
onSave() {
|
||||
onBeforeUnload(false);
|
||||
$('#alert-container').html('');
|
||||
},
|
||||
loadPreSelectFilters() {
|
||||
try {
|
||||
const filters = JSON.parse(px.getParam('preselect_filters') || '{}');
|
||||
for (const sliceId in filters) {
|
||||
for (const col in filters[sliceId]) {
|
||||
this.setFilter(sliceId, col, filters[sliceId][col], false, false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error(e);
|
||||
}
|
||||
},
|
||||
setFilter(sliceId, col, vals, refresh) {
|
||||
this.addFilter(sliceId, col, vals, false, refresh);
|
||||
},
|
||||
done(slice) {
|
||||
const refresh = slice.getWidgetHeader().find('.refresh');
|
||||
const data = slice.data;
|
||||
const cachedWhen = moment.utc(data.cached_dttm).fromNow();
|
||||
if (data !== undefined && data.is_cached) {
|
||||
refresh
|
||||
.addClass('danger')
|
||||
.attr(
|
||||
'title',
|
||||
t('Served from data cached %s . Click to force refresh.', cachedWhen))
|
||||
.tooltip('fixTitle');
|
||||
} else {
|
||||
refresh
|
||||
.removeClass('danger')
|
||||
.attr('title', t('Click to force refresh'))
|
||||
.tooltip('fixTitle');
|
||||
}
|
||||
},
|
||||
effectiveExtraFilters(sliceId) {
|
||||
const f = [];
|
||||
const immuneSlices = this.metadata.filter_immune_slices || [];
|
||||
if (sliceId && immuneSlices.includes(sliceId)) {
|
||||
// The slice is immune to dashboard filters
|
||||
return f;
|
||||
}
|
||||
|
||||
// Building a list of fields the slice is immune to filters on
|
||||
let immuneToFields = [];
|
||||
if (
|
||||
sliceId &&
|
||||
this.metadata.filter_immune_slice_fields &&
|
||||
this.metadata.filter_immune_slice_fields[sliceId]) {
|
||||
immuneToFields = this.metadata.filter_immune_slice_fields[sliceId];
|
||||
}
|
||||
for (const filteringSliceId in this.filters) {
|
||||
if (filteringSliceId === sliceId.toString()) {
|
||||
// Filters applied by the slice don't apply to itself
|
||||
continue;
|
||||
}
|
||||
for (const field in this.filters[filteringSliceId]) {
|
||||
if (!immuneToFields.includes(field)) {
|
||||
f.push({
|
||||
col: field,
|
||||
op: 'in',
|
||||
val: this.filters[filteringSliceId][field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return f;
|
||||
},
|
||||
addFilter(sliceId, col, vals, merge = true, refresh = true) {
|
||||
if (
|
||||
this.getSlice(sliceId) && (
|
||||
['__from', '__to', '__time_col', '__time_grain', '__time_origin', '__granularity']
|
||||
.indexOf(col) >= 0 ||
|
||||
this.getSlice(sliceId).formData.groupby.indexOf(col) !== -1
|
||||
)
|
||||
) {
|
||||
if (!(sliceId in this.filters)) {
|
||||
this.filters[sliceId] = {};
|
||||
}
|
||||
if (!(col in this.filters[sliceId]) || !merge) {
|
||||
this.filters[sliceId][col] = vals;
|
||||
|
||||
// d3.merge pass in array of arrays while some value form filter components
|
||||
// from and to filter box require string to be process and return
|
||||
} else if (this.filters[sliceId][col] instanceof Array) {
|
||||
this.filters[sliceId][col] = d3.merge([this.filters[sliceId][col], vals]);
|
||||
} else {
|
||||
this.filters[sliceId][col] = d3.merge([[this.filters[sliceId][col]], vals])[0] || '';
|
||||
}
|
||||
if (refresh) {
|
||||
this.refreshExcept(sliceId);
|
||||
}
|
||||
}
|
||||
this.updateFilterParamsInUrl();
|
||||
},
|
||||
readFilters() {
|
||||
// Returns a list of human readable active filters
|
||||
return JSON.stringify(this.filters, null, ' ');
|
||||
},
|
||||
updateFilterParamsInUrl() {
|
||||
const urlObj = urlLib.parse(location.href, true);
|
||||
urlObj.query = urlObj.query || {};
|
||||
urlObj.query.preselect_filters = this.readFilters();
|
||||
urlObj.search = null;
|
||||
history.pushState(urlObj.query, window.title, urlLib.format(urlObj));
|
||||
},
|
||||
bindResizeToWindowResize() {
|
||||
let resizeTimer;
|
||||
const dash = this;
|
||||
$(window).on('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
dash.sliceObjects.forEach((slice) => {
|
||||
slice.resize();
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
},
|
||||
stopPeriodicRender() {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
renderSlices(slices, force = false, interval = 0) {
|
||||
if (!interval) {
|
||||
slices.forEach(slice => slice.render(force));
|
||||
return;
|
||||
}
|
||||
const meta = this.metadata;
|
||||
const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
|
||||
if (typeof meta.stagger_refresh !== 'boolean') {
|
||||
meta.stagger_refresh = meta.stagger_refresh === undefined ?
|
||||
true : meta.stagger_refresh === 'true';
|
||||
}
|
||||
const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
|
||||
slices.forEach((slice, i) => {
|
||||
setTimeout(() => slice.render(force), delay * i);
|
||||
});
|
||||
},
|
||||
startPeriodicRender(interval) {
|
||||
this.stopPeriodicRender();
|
||||
const dash = this;
|
||||
const immune = this.metadata.timed_refresh_immune_slices || [];
|
||||
const refreshAll = () => {
|
||||
const slices = dash.sliceObjects
|
||||
.filter(slice => immune.indexOf(slice.data.slice_id) === -1);
|
||||
dash.renderSlices(slices, true, interval * 0.2);
|
||||
};
|
||||
const fetchAndRender = function () {
|
||||
refreshAll();
|
||||
if (interval > 0) {
|
||||
dash.refreshTimer = setTimeout(function () {
|
||||
fetchAndRender();
|
||||
}, interval);
|
||||
}
|
||||
};
|
||||
fetchAndRender();
|
||||
},
|
||||
refreshExcept(sliceId) {
|
||||
const immune = this.metadata.filter_immune_slices || [];
|
||||
const slices = this.sliceObjects.filter(slice =>
|
||||
slice.data.slice_id !== sliceId && immune.indexOf(slice.data.slice_id) === -1);
|
||||
this.renderSlices(slices);
|
||||
},
|
||||
clearFilters(sliceId) {
|
||||
delete this.filters[sliceId];
|
||||
this.refreshExcept(sliceId);
|
||||
this.updateFilterParamsInUrl();
|
||||
},
|
||||
removeFilter(sliceId, col, vals) {
|
||||
if (sliceId in this.filters) {
|
||||
if (col in this.filters[sliceId]) {
|
||||
const a = [];
|
||||
this.filters[sliceId][col].forEach(function (v) {
|
||||
if (vals.indexOf(v) < 0) {
|
||||
a.push(v);
|
||||
}
|
||||
});
|
||||
this.filters[sliceId][col] = a;
|
||||
}
|
||||
}
|
||||
this.refreshExcept(sliceId);
|
||||
this.updateFilterParamsInUrl();
|
||||
},
|
||||
getSlice(sliceId) {
|
||||
const id = parseInt(sliceId, 10);
|
||||
let i = 0;
|
||||
let slice = null;
|
||||
while (i < this.sliceObjects.length) {
|
||||
// when the slice is found, assign to slice and break;
|
||||
if (this.sliceObjects[i].data.slice_id === id) {
|
||||
slice = this.sliceObjects[i];
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return slice;
|
||||
},
|
||||
getAjaxErrorMsg(error) {
|
||||
const respJSON = error.responseJSON;
|
||||
return (respJSON && respJSON.message) ? respJSON.message :
|
||||
error.responseText;
|
||||
},
|
||||
addSlicesToDashboard(sliceIds) {
|
||||
const getAjaxErrorMsg = this.getAjaxErrorMsg;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/superset/add_slices/${dashboard.id}/`,
|
||||
data: {
|
||||
data: JSON.stringify({ slice_ids: sliceIds }),
|
||||
},
|
||||
success() {
|
||||
// Refresh page to allow for slices to re-render
|
||||
window.location.reload();
|
||||
},
|
||||
error(error) {
|
||||
const errorMsg = getAjaxErrorMsg(error);
|
||||
utils.showModal({
|
||||
title: t('Error'),
|
||||
body: t('Sorry, there was an error adding slices to this dashboard: %s', errorMsg),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
updateDashboardTitle(title) {
|
||||
this.dashboard_title = title;
|
||||
this.onChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(() => {
|
||||
// Getting bootstrapped data from the DOM
|
||||
utils.initJQueryAjax();
|
||||
const dashboardData = $('.dashboard').data('bootstrap');
|
||||
|
||||
const state = getInitialState(dashboardData);
|
||||
px = superset(state);
|
||||
const dashboard = dashboardContainer(state.dashboard, state.datasources, state.user_id);
|
||||
initDashboardView(dashboard);
|
||||
dashboard.init();
|
||||
});
|
||||
117
superset/assets/javascripts/dashboard/actions.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/* global notify */
|
||||
import $ from 'jquery';
|
||||
import { getExploreUrl } from '../explore/exploreUtils';
|
||||
|
||||
export const ADD_FILTER = 'ADD_FILTER';
|
||||
export function addFilter(sliceId, col, vals, merge = true, refresh = true) {
|
||||
return { type: ADD_FILTER, sliceId, col, vals, merge, refresh };
|
||||
}
|
||||
|
||||
export const CLEAR_FILTER = 'CLEAR_FILTER';
|
||||
export function clearFilter(sliceId) {
|
||||
return { type: CLEAR_FILTER, sliceId };
|
||||
}
|
||||
|
||||
export const REMOVE_FILTER = 'REMOVE_FILTER';
|
||||
export function removeFilter(sliceId, col, vals, refresh = true) {
|
||||
return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
|
||||
}
|
||||
|
||||
export const UPDATE_DASHBOARD_LAYOUT = 'UPDATE_DASHBOARD_LAYOUT';
|
||||
export function updateDashboardLayout(layout) {
|
||||
return { type: UPDATE_DASHBOARD_LAYOUT, layout };
|
||||
}
|
||||
|
||||
export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
|
||||
export function updateDashboardTitle(title) {
|
||||
return { type: UPDATE_DASHBOARD_TITLE, title };
|
||||
}
|
||||
|
||||
export function addSlicesToDashboard(dashboardId, sliceIds) {
|
||||
return () => (
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: `/superset/add_slices/${dashboardId}/`,
|
||||
data: {
|
||||
data: JSON.stringify({ slice_ids: sliceIds }),
|
||||
},
|
||||
})
|
||||
.done(() => {
|
||||
// Refresh page to allow for slices to re-render
|
||||
window.location.reload();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export const REMOVE_SLICE = 'REMOVE_SLICE';
|
||||
export function removeSlice(slice) {
|
||||
return { type: REMOVE_SLICE, slice };
|
||||
}
|
||||
|
||||
export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
|
||||
export function updateSliceName(slice, sliceName) {
|
||||
return { type: UPDATE_SLICE_NAME, slice, sliceName };
|
||||
}
|
||||
export function saveSlice(slice, sliceName) {
|
||||
const oldName = slice.slice_name;
|
||||
return (dispatch) => {
|
||||
const sliceParams = {};
|
||||
sliceParams.slice_id = slice.slice_id;
|
||||
sliceParams.action = 'overwrite';
|
||||
sliceParams.slice_name = sliceName;
|
||||
const saveUrl = getExploreUrl(slice.form_data, 'base', false, null, sliceParams);
|
||||
return $.ajax({
|
||||
url: saveUrl,
|
||||
type: 'GET',
|
||||
success: () => {
|
||||
dispatch(updateSliceName(slice, sliceName));
|
||||
notify.success('This slice name was saved successfully.');
|
||||
},
|
||||
error: () => {
|
||||
// if server-side reject the overwrite action,
|
||||
// revert to old state
|
||||
dispatch(updateSliceName(slice, oldName));
|
||||
notify.error("You don't have the rights to alter this slice");
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
|
||||
export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
|
||||
export function toggleFaveStar(isStarred) {
|
||||
return { type: TOGGLE_FAVE_STAR, isStarred };
|
||||
}
|
||||
|
||||
export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
|
||||
export function fetchFaveStar(id) {
|
||||
return function (dispatch) {
|
||||
const url = `${FAVESTAR_BASE_URL}/${id}/count`;
|
||||
return $.get(url)
|
||||
.done((data) => {
|
||||
if (data.count > 0) {
|
||||
dispatch(toggleFaveStar(true));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
|
||||
export function saveFaveStar(id, isStarred) {
|
||||
return function (dispatch) {
|
||||
const urlSuffix = isStarred ? 'unselect' : 'select';
|
||||
const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
|
||||
$.get(url);
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
};
|
||||
}
|
||||
|
||||
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
|
||||
export function toggleExpandSlice(slice, isExpanded) {
|
||||
return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
|
||||
}
|
||||
|
||||
export const SET_EDIT_MODE = 'SET_EDIT_MODE';
|
||||
export function setEditMode(editMode) {
|
||||
return { type: SET_EDIT_MODE, editMode };
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default class CodeModal extends React.PureComponent {
|
||||
}
|
||||
beforeOpen() {
|
||||
let code = this.props.code;
|
||||
if (this.props.codeCallback) {
|
||||
if (!code && this.props.codeCallback) {
|
||||
code = this.props.codeCallback();
|
||||
}
|
||||
this.setState({ code });
|
||||
|
||||
@@ -1,19 +1,59 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ButtonGroup } from 'react-bootstrap';
|
||||
import { DropdownButton, MenuItem } from 'react-bootstrap';
|
||||
|
||||
import Button from '../../components/Button';
|
||||
import CssEditor from './CssEditor';
|
||||
import RefreshIntervalModal from './RefreshIntervalModal';
|
||||
import SaveModal from './SaveModal';
|
||||
import CodeModal from './CodeModal';
|
||||
import SliceAdder from './SliceAdder';
|
||||
import { t } from '../../locales';
|
||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
slices: PropTypes.array,
|
||||
userId: PropTypes.string.isRequired,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
renderSlices: PropTypes.func,
|
||||
serialize: PropTypes.func,
|
||||
startPeriodicRender: PropTypes.func,
|
||||
editMode: PropTypes.bool,
|
||||
};
|
||||
|
||||
function MenuItemContent({ faIcon, text, tooltip, children }) {
|
||||
return (
|
||||
<span>
|
||||
<i className={`fa fa-${faIcon}`} /> {text} {''}
|
||||
<InfoTooltipWithTrigger
|
||||
tooltip={tooltip}
|
||||
label={`dash-${faIcon}`}
|
||||
placement="top"
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
MenuItemContent.propTypes = {
|
||||
faIcon: PropTypes.string.isRequired,
|
||||
text: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
function ActionMenuItem(props) {
|
||||
return (
|
||||
<MenuItem onClick={props.onClick}>
|
||||
<MenuItemContent {...props} />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
ActionMenuItem.propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
class Controls extends React.PureComponent {
|
||||
@@ -23,8 +63,13 @@ class Controls extends React.PureComponent {
|
||||
css: props.dashboard.css || '',
|
||||
cssTemplates: [],
|
||||
};
|
||||
this.refresh = this.refresh.bind(this);
|
||||
this.toggleModal = this.toggleModal.bind(this);
|
||||
this.updateDom = this.updateDom.bind(this);
|
||||
}
|
||||
componentWillMount() {
|
||||
this.updateDom(this.state.css);
|
||||
|
||||
$.get('/csstemplateasyncmodelview/api/read', (data) => {
|
||||
const cssTemplates = data.result.map(row => ({
|
||||
value: row.template_name,
|
||||
@@ -36,74 +81,129 @@ class Controls extends React.PureComponent {
|
||||
}
|
||||
refresh() {
|
||||
// Force refresh all slices
|
||||
this.props.dashboard.renderSlices(this.props.dashboard.sliceObjects, true);
|
||||
this.props.renderSlices(true);
|
||||
}
|
||||
toggleModal(modal) {
|
||||
let currentModal;
|
||||
if (modal !== this.state.currentModal) {
|
||||
currentModal = modal;
|
||||
}
|
||||
this.setState({ currentModal });
|
||||
}
|
||||
changeCss(css) {
|
||||
this.setState({ css });
|
||||
this.props.dashboard.onChange();
|
||||
this.setState({ css }, () => {
|
||||
this.updateDom(css);
|
||||
});
|
||||
this.props.onChange();
|
||||
}
|
||||
updateDom(css) {
|
||||
const className = 'CssEditor-css';
|
||||
const head = document.head || document.getElementsByTagName('head')[0];
|
||||
let style = document.querySelector('.' + className);
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.className = className;
|
||||
style.type = 'text/css';
|
||||
head.appendChild(style);
|
||||
}
|
||||
if (style.styleSheet) {
|
||||
style.styleSheet.cssText = css;
|
||||
} else {
|
||||
style.innerHTML = css;
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const dashboard = this.props.dashboard;
|
||||
const { dashboard, userId, filters,
|
||||
addSlicesToDashboard, startPeriodicRender,
|
||||
serialize, onSave, editMode } = this.props;
|
||||
const emailBody = t('Checkout this dashboard: %s', window.location.href);
|
||||
const emailLink = 'mailto:?Subject=Superset%20Dashboard%20'
|
||||
+ `${dashboard.dashboard_title}&Body=${emailBody}`;
|
||||
let saveText = t('Save as');
|
||||
if (editMode) {
|
||||
saveText = t('Save');
|
||||
}
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
tooltip={t('Force refresh the whole dashboard')}
|
||||
onClick={this.refresh.bind(this)}
|
||||
>
|
||||
<i className="fa fa-refresh" />
|
||||
</Button>
|
||||
<SliceAdder
|
||||
dashboard={dashboard}
|
||||
triggerNode={
|
||||
<i className="fa fa-plus" />
|
||||
<span>
|
||||
<DropdownButton title="Actions" bsSize="small" id="bg-nested-dropdown" pullRight>
|
||||
<ActionMenuItem
|
||||
text={t('Force Refresh')}
|
||||
tooltip={t('Force refresh the whole dashboard')}
|
||||
faIcon="refresh"
|
||||
onClick={this.refresh}
|
||||
/>
|
||||
<RefreshIntervalModal
|
||||
onChange={refreshInterval => startPeriodicRender(refreshInterval * 1000)}
|
||||
triggerNode={
|
||||
<MenuItemContent
|
||||
text={t('Set autorefresh')}
|
||||
tooltip={t('Set the auto-refresh interval for this session')}
|
||||
faIcon="clock-o"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SaveModal
|
||||
dashboard={dashboard}
|
||||
filters={filters}
|
||||
serialize={serialize}
|
||||
onSave={onSave}
|
||||
css={this.state.css}
|
||||
triggerNode={
|
||||
<MenuItemContent
|
||||
text={saveText}
|
||||
tooltip={t('Save the dashboard')}
|
||||
faIcon="save"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{editMode &&
|
||||
<ActionMenuItem
|
||||
text={t('Edit properties')}
|
||||
tooltip={t("Edit the dashboards's properties")}
|
||||
faIcon="edit"
|
||||
onClick={() => { window.location = `/dashboardmodelview/edit/${dashboard.id}`; }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<RefreshIntervalModal
|
||||
onChange={refreshInterval => dashboard.startPeriodicRender(refreshInterval * 1000)}
|
||||
triggerNode={
|
||||
<i className="fa fa-clock-o" />
|
||||
{editMode &&
|
||||
<ActionMenuItem
|
||||
text={t('Email')}
|
||||
tooltip={t('Email a link to this dashbaord')}
|
||||
onClick={() => { window.location = emailLink; }}
|
||||
faIcon="envelope"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CodeModal
|
||||
codeCallback={dashboard.readFilters.bind(dashboard)}
|
||||
triggerNode={<i className="fa fa-filter" />}
|
||||
/>
|
||||
<CssEditor
|
||||
dashboard={dashboard}
|
||||
triggerNode={
|
||||
<i className="fa fa-css3" />
|
||||
{editMode &&
|
||||
<SliceAdder
|
||||
dashboard={dashboard}
|
||||
addSlicesToDashboard={addSlicesToDashboard}
|
||||
userId={userId}
|
||||
triggerNode={
|
||||
<MenuItemContent
|
||||
text={t('Add Slices')}
|
||||
tooltip={t('Add some slices to this dashbaord')}
|
||||
faIcon="plus"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
initialCss={dashboard.css}
|
||||
templates={this.state.cssTemplates}
|
||||
onChange={this.changeCss.bind(this)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => { window.location = emailLink; }}
|
||||
>
|
||||
<i className="fa fa-envelope" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!dashboard.dash_edit_perm}
|
||||
onClick={() => {
|
||||
window.location = `/dashboardmodelview/edit/${dashboard.id}`;
|
||||
}}
|
||||
tooltip={t('Edit this dashboard\'s properties')}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</Button>
|
||||
<SaveModal
|
||||
dashboard={dashboard}
|
||||
css={this.state.css}
|
||||
triggerNode={
|
||||
<Button disabled={!dashboard.dash_save_perm}>
|
||||
<i className="fa fa-save" />
|
||||
</Button>
|
||||
{editMode &&
|
||||
<CssEditor
|
||||
dashboard={dashboard}
|
||||
triggerNode={
|
||||
<MenuItemContent
|
||||
text={t('Edit CSS')}
|
||||
tooltip={t('Change the style of the dashboard using CSS code')}
|
||||
faIcon="css3"
|
||||
/>
|
||||
}
|
||||
initialCss={this.state.css}
|
||||
templates={this.state.cssTemplates}
|
||||
onChange={this.changeCss.bind(this)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</DropdownButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,30 +30,10 @@ class CssEditor extends React.PureComponent {
|
||||
cssTemplateOptions: [],
|
||||
};
|
||||
}
|
||||
componentWillMount() {
|
||||
this.updateDom();
|
||||
}
|
||||
changeCss(css) {
|
||||
this.setState({ css }, this.updateDom);
|
||||
this.props.onChange(css);
|
||||
}
|
||||
updateDom() {
|
||||
const css = this.state.css;
|
||||
const className = 'CssEditor-css';
|
||||
const head = document.head || document.getElementsByTagName('head')[0];
|
||||
let style = document.querySelector('.' + className);
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.className = className;
|
||||
style.type = 'text/css';
|
||||
head.appendChild(style);
|
||||
}
|
||||
if (style.styleSheet) {
|
||||
style.styleSheet.cssText = css;
|
||||
} else {
|
||||
style.innerHTML = css;
|
||||
}
|
||||
this.setState({ css }, () => {
|
||||
this.props.onChange(css);
|
||||
});
|
||||
}
|
||||
changeCssTemplate(opt) {
|
||||
this.changeCss(opt.css);
|
||||
@@ -78,7 +58,7 @@ class CssEditor extends React.PureComponent {
|
||||
<ModalTrigger
|
||||
triggerNode={this.props.triggerNode}
|
||||
modalTitle={t('CSS')}
|
||||
isButton
|
||||
isMenuItem
|
||||
modalBody={
|
||||
<div>
|
||||
{this.renderTemplateSelector()}
|
||||
|
||||
340
superset/assets/javascripts/dashboard/components/Dashboard.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import AlertsWrapper from '../../components/AlertsWrapper';
|
||||
import GridLayout from './GridLayout';
|
||||
import Header from './Header';
|
||||
import { areObjectsEqual } from '../../reduxUtils';
|
||||
import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
|
||||
LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
|
||||
import { t } from '../../locales';
|
||||
|
||||
import '../../../stylesheets/dashboard.css';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object,
|
||||
initMessages: PropTypes.array,
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
slices: PropTypes.object,
|
||||
datasources: PropTypes.object,
|
||||
filters: PropTypes.object,
|
||||
refresh: PropTypes.bool,
|
||||
timeout: PropTypes.number,
|
||||
userId: PropTypes.string,
|
||||
isStarred: PropTypes.bool,
|
||||
editMode: PropTypes.bool,
|
||||
impressionId: PropTypes.string,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
initMessages: [],
|
||||
dashboard: {},
|
||||
slices: {},
|
||||
datasources: {},
|
||||
filters: {},
|
||||
refresh: false,
|
||||
timeout: 60,
|
||||
userId: '',
|
||||
isStarred: false,
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
class Dashboard extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.refreshTimer = null;
|
||||
this.firstLoad = true;
|
||||
this.loadingLog = new ActionLog({
|
||||
impressionId: props.impressionId,
|
||||
actionType: LOG_ACTIONS_PAGE_LOAD,
|
||||
source: 'dashboard',
|
||||
sourceId: props.dashboard.id,
|
||||
eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
|
||||
});
|
||||
Logger.start(this.loadingLog);
|
||||
|
||||
// alert for unsaved changes
|
||||
this.state = { unsavedChanges: false };
|
||||
|
||||
this.rerenderCharts = this.rerenderCharts.bind(this);
|
||||
this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
|
||||
this.onSave = this.onSave.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.serialize = this.serialize.bind(this);
|
||||
this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
|
||||
this.startPeriodicRender = this.startPeriodicRender.bind(this);
|
||||
this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
|
||||
this.fetchSlice = this.fetchSlice.bind(this);
|
||||
this.getFormDataExtra = this.getFormDataExtra.bind(this);
|
||||
this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
|
||||
this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
|
||||
this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
|
||||
this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
|
||||
this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
|
||||
this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
|
||||
this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
|
||||
this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
|
||||
this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
|
||||
this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.rerenderCharts);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.firstLoad &&
|
||||
Object.values(nextProps.slices)
|
||||
.every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
|
||||
) {
|
||||
Logger.end(this.loadingLog);
|
||||
this.firstLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.refresh) {
|
||||
let changedFilterKey;
|
||||
const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
|
||||
Object.keys(this.props.filters).some((key) => {
|
||||
prevFiltersKeySet.delete(key);
|
||||
if (prevProps.filters[key] === undefined ||
|
||||
!areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
|
||||
changedFilterKey = key;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// has changed filter or removed a filter?
|
||||
if (!!changedFilterKey || prevFiltersKeySet.size) {
|
||||
this.refreshExcept(changedFilterKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.rerenderCharts);
|
||||
}
|
||||
|
||||
onBeforeUnload(hasChanged) {
|
||||
if (hasChanged) {
|
||||
window.addEventListener('beforeunload', this.unload);
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', this.unload);
|
||||
}
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.onBeforeUnload(true);
|
||||
this.setState({ unsavedChanges: true });
|
||||
}
|
||||
|
||||
onSave() {
|
||||
this.onBeforeUnload(false);
|
||||
this.setState({ unsavedChanges: false });
|
||||
}
|
||||
|
||||
// return charts in array
|
||||
getAllSlices() {
|
||||
return Object.values(this.props.slices);
|
||||
}
|
||||
|
||||
getFormDataExtra(slice) {
|
||||
const formDataExtra = Object.assign({}, slice.formData);
|
||||
const extraFilters = this.effectiveExtraFilters(slice.slice_id);
|
||||
formDataExtra.extra_filters = formDataExtra.filters.concat(extraFilters);
|
||||
return formDataExtra;
|
||||
}
|
||||
|
||||
getFilters(sliceId) {
|
||||
return this.props.filters[sliceId];
|
||||
}
|
||||
|
||||
unload() {
|
||||
const message = t('You have unsaved changes.');
|
||||
window.event.returnValue = message; // Gecko + IE
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
effectiveExtraFilters(sliceId) {
|
||||
const metadata = this.props.dashboard.metadata;
|
||||
const filters = this.props.filters;
|
||||
const f = [];
|
||||
const immuneSlices = metadata.filter_immune_slices || [];
|
||||
if (sliceId && immuneSlices.includes(sliceId)) {
|
||||
// The slice is immune to dashboard filters
|
||||
return f;
|
||||
}
|
||||
|
||||
// Building a list of fields the slice is immune to filters on
|
||||
let immuneToFields = [];
|
||||
if (
|
||||
sliceId &&
|
||||
metadata.filter_immune_slice_fields &&
|
||||
metadata.filter_immune_slice_fields[sliceId]) {
|
||||
immuneToFields = metadata.filter_immune_slice_fields[sliceId];
|
||||
}
|
||||
for (const filteringSliceId in filters) {
|
||||
if (filteringSliceId === sliceId.toString()) {
|
||||
// Filters applied by the slice don't apply to itself
|
||||
continue;
|
||||
}
|
||||
for (const field in filters[filteringSliceId]) {
|
||||
if (!immuneToFields.includes(field)) {
|
||||
f.push({
|
||||
col: field,
|
||||
op: 'in',
|
||||
val: filters[filteringSliceId][field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
refreshExcept(filterKey) {
|
||||
const immune = this.props.dashboard.metadata.filter_immune_slices || [];
|
||||
let slices = this.getAllSlices();
|
||||
if (filterKey) {
|
||||
slices = slices.filter(slice => (
|
||||
String(slice.slice_id) !== filterKey &&
|
||||
immune.indexOf(slice.slice_id) === -1
|
||||
));
|
||||
}
|
||||
this.fetchSlices(slices);
|
||||
}
|
||||
|
||||
stopPeriodicRender() {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRender(interval) {
|
||||
this.stopPeriodicRender();
|
||||
const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
|
||||
const refreshAll = () => {
|
||||
const affectedSlices = this.getAllSlices()
|
||||
.filter(slice => immune.indexOf(slice.slice_id) === -1);
|
||||
this.fetchSlices(affectedSlices, true, interval * 0.2);
|
||||
};
|
||||
const fetchAndRender = () => {
|
||||
refreshAll();
|
||||
if (interval > 0) {
|
||||
this.refreshTimer = setTimeout(fetchAndRender, interval);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndRender();
|
||||
}
|
||||
|
||||
updateDashboardTitle(title) {
|
||||
this.props.actions.updateDashboardTitle(title);
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.props.dashboard.layout.map(reactPos => ({
|
||||
slice_id: reactPos.i,
|
||||
col: reactPos.x + 1,
|
||||
row: reactPos.y,
|
||||
size_x: reactPos.w,
|
||||
size_y: reactPos.h,
|
||||
}));
|
||||
}
|
||||
|
||||
addSlicesToDashboard(sliceIds) {
|
||||
return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
|
||||
}
|
||||
|
||||
fetchSlice(slice, force = false) {
|
||||
return this.props.actions.runQuery(
|
||||
this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
|
||||
);
|
||||
}
|
||||
|
||||
// fetch and render an list of slices
|
||||
fetchSlices(slc, force = false, interval = 0) {
|
||||
const slices = slc || this.getAllSlices();
|
||||
if (!interval) {
|
||||
slices.forEach((slice) => { this.fetchSlice(slice, force); });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = this.props.dashboard.metadata;
|
||||
const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
|
||||
if (typeof meta.stagger_refresh !== 'boolean') {
|
||||
meta.stagger_refresh = meta.stagger_refresh === undefined ?
|
||||
true : meta.stagger_refresh === 'true';
|
||||
}
|
||||
const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
|
||||
slices.forEach((slice, i) => {
|
||||
setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
|
||||
});
|
||||
}
|
||||
|
||||
// re-render chart without fetch
|
||||
rerenderCharts() {
|
||||
this.getAllSlices().forEach((slice) => {
|
||||
setTimeout(() => {
|
||||
this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="dashboard-container">
|
||||
<div id="dashboard-header">
|
||||
<AlertsWrapper initMessages={this.props.initMessages} />
|
||||
<Header
|
||||
dashboard={this.props.dashboard}
|
||||
unsavedChanges={this.state.unsavedChanges}
|
||||
filters={this.props.filters}
|
||||
userId={this.props.userId}
|
||||
isStarred={this.props.isStarred}
|
||||
updateDashboardTitle={this.updateDashboardTitle}
|
||||
onSave={this.onSave}
|
||||
onChange={this.onChange}
|
||||
serialize={this.serialize}
|
||||
fetchFaveStar={this.props.actions.fetchFaveStar}
|
||||
saveFaveStar={this.props.actions.saveFaveStar}
|
||||
renderSlices={this.fetchAllSlices}
|
||||
startPeriodicRender={this.startPeriodicRender}
|
||||
addSlicesToDashboard={this.addSlicesToDashboard}
|
||||
editMode={this.props.editMode}
|
||||
setEditMode={this.props.actions.setEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div id="grid-container" className="slice-grid gridster">
|
||||
<GridLayout
|
||||
dashboard={this.props.dashboard}
|
||||
datasources={this.props.datasources}
|
||||
filters={this.props.filters}
|
||||
charts={this.props.slices}
|
||||
timeout={this.props.timeout}
|
||||
onChange={this.onChange}
|
||||
getFormDataExtra={this.getFormDataExtra}
|
||||
fetchSlice={this.fetchSlice}
|
||||
saveSlice={this.props.actions.saveSlice}
|
||||
removeSlice={this.props.actions.removeSlice}
|
||||
removeChart={this.props.actions.removeChart}
|
||||
updateDashboardLayout={this.props.actions.updateDashboardLayout}
|
||||
toggleExpandSlice={this.props.actions.toggleExpandSlice}
|
||||
addFilter={this.props.actions.addFilter}
|
||||
getFilters={this.getFilters}
|
||||
clearFilter={this.props.actions.clearFilter}
|
||||
removeFilter={this.props.actions.removeFilter}
|
||||
editMode={this.props.editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard.propTypes = propTypes;
|
||||
Dashboard.defaultProps = defaultProps;
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import * as dashboardActions from '../actions';
|
||||
import * as chartActions from '../../chart/chartAction';
|
||||
import Dashboard from './Dashboard';
|
||||
|
||||
function mapStateToProps({ charts, dashboard, impressionId }) {
|
||||
return {
|
||||
initMessages: dashboard.common.flash_messages,
|
||||
timeout: dashboard.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
dashboard: dashboard.dashboard,
|
||||
slices: charts,
|
||||
datasources: dashboard.datasources,
|
||||
filters: dashboard.filters,
|
||||
refresh: !!dashboard.refresh,
|
||||
userId: dashboard.userId,
|
||||
isStarred: !!dashboard.isStarred,
|
||||
editMode: dashboard.editMode,
|
||||
impressionId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
const actions = { ...chartActions, ...dashboardActions };
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
|
||||
142
superset/assets/javascripts/dashboard/components/GridCell.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import SliceHeader from './SliceHeader';
|
||||
import ChartContainer from '../../chart/ChartContainer';
|
||||
|
||||
import '../../../stylesheets/dashboard.css';
|
||||
|
||||
const propTypes = {
|
||||
timeout: PropTypes.number,
|
||||
datasource: PropTypes.object,
|
||||
isLoading: PropTypes.bool,
|
||||
isCached: PropTypes.bool,
|
||||
cachedDttm: PropTypes.string,
|
||||
isExpanded: PropTypes.bool,
|
||||
widgetHeight: PropTypes.number,
|
||||
widgetWidth: PropTypes.number,
|
||||
exploreChartUrl: PropTypes.string,
|
||||
exportCSVUrl: PropTypes.string,
|
||||
slice: PropTypes.object,
|
||||
chartKey: PropTypes.string,
|
||||
formData: PropTypes.object,
|
||||
filters: PropTypes.object,
|
||||
forceRefresh: PropTypes.func,
|
||||
removeSlice: PropTypes.func,
|
||||
updateSliceName: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
editMode: PropTypes.bool,
|
||||
annotationQuery: PropTypes.object,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
forceRefresh: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
updateSliceName: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
class GridCell extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const sliceId = this.props.slice.slice_id;
|
||||
this.addFilter = this.props.addFilter.bind(this, sliceId);
|
||||
this.getFilters = this.props.getFilters.bind(this, sliceId);
|
||||
this.clearFilter = this.props.clearFilter.bind(this, sliceId);
|
||||
this.removeFilter = this.props.removeFilter.bind(this, sliceId);
|
||||
}
|
||||
|
||||
getDescriptionId(slice) {
|
||||
return 'description_' + slice.slice_id;
|
||||
}
|
||||
|
||||
getHeaderId(slice) {
|
||||
return 'header_' + slice.slice_id;
|
||||
}
|
||||
|
||||
width() {
|
||||
return this.props.widgetWidth - 10;
|
||||
}
|
||||
|
||||
height(slice) {
|
||||
const widgetHeight = this.props.widgetHeight;
|
||||
const headerId = this.getHeaderId(slice);
|
||||
const descriptionId = this.getDescriptionId(slice);
|
||||
const headerHeight = this.refs[headerId] ? this.refs[headerId].offsetHeight : 30;
|
||||
let descriptionHeight = 0;
|
||||
if (this.props.isExpanded && this.refs[descriptionId]) {
|
||||
descriptionHeight = this.refs[descriptionId].offsetHeight + 10;
|
||||
}
|
||||
return widgetHeight - headerHeight - descriptionHeight;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
exploreChartUrl, exportCSVUrl, isExpanded, isLoading, isCached, cachedDttm,
|
||||
removeSlice, updateSliceName, toggleExpandSlice, forceRefresh,
|
||||
chartKey, slice, datasource, formData, timeout, annotationQuery,
|
||||
} = this.props;
|
||||
return (
|
||||
<div
|
||||
className={isLoading ? 'slice-cell-highlight' : 'slice-cell'}
|
||||
id={`${slice.slice_id}-cell`}
|
||||
>
|
||||
<div ref={this.getHeaderId(slice)}>
|
||||
<SliceHeader
|
||||
slice={slice}
|
||||
exploreChartUrl={exploreChartUrl}
|
||||
exportCSVUrl={exportCSVUrl}
|
||||
isExpanded={isExpanded}
|
||||
isCached={isCached}
|
||||
cachedDttm={cachedDttm}
|
||||
removeSlice={removeSlice}
|
||||
updateSliceName={updateSliceName}
|
||||
toggleExpandSlice={toggleExpandSlice}
|
||||
forceRefresh={forceRefresh}
|
||||
editMode={this.props.editMode}
|
||||
annotationQuery={annotationQuery}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="slice_description bs-callout bs-callout-default"
|
||||
style={isExpanded ? {} : { display: 'none' }}
|
||||
ref={this.getDescriptionId(slice)}
|
||||
dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
|
||||
/>
|
||||
<div className="row chart-container">
|
||||
<input type="hidden" value="false" />
|
||||
<ChartContainer
|
||||
containerId={`slice-container-${slice.slice_id}`}
|
||||
chartKey={chartKey}
|
||||
datasource={datasource}
|
||||
formData={formData}
|
||||
height={this.height(slice)}
|
||||
width={this.width()}
|
||||
timeout={timeout}
|
||||
vizType={slice.formData.viz_type}
|
||||
addFilter={this.addFilter}
|
||||
getFilters={this.getFilters}
|
||||
clearFilter={this.clearFilter}
|
||||
removeFilter={this.removeFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GridCell.propTypes = propTypes;
|
||||
GridCell.defaultProps = defaultProps;
|
||||
|
||||
export default GridCell;
|
||||
@@ -1,10 +1,8 @@
|
||||
/* global notify */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import $ from 'jquery';
|
||||
|
||||
import SliceCell from './SliceCell';
|
||||
import GridCell from './GridCell';
|
||||
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||
|
||||
require('react-grid-layout/css/styles.css');
|
||||
@@ -14,119 +12,170 @@ const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
datasources: PropTypes.object,
|
||||
charts: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object,
|
||||
timeout: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
getFormDataExtra: PropTypes.func,
|
||||
fetchSlice: PropTypes.func,
|
||||
saveSlice: PropTypes.func,
|
||||
removeSlice: PropTypes.func,
|
||||
removeChart: PropTypes.func,
|
||||
updateDashboardLayout: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
addFilter: PropTypes.func,
|
||||
getFilters: PropTypes.func,
|
||||
clearFilter: PropTypes.func,
|
||||
removeFilter: PropTypes.func,
|
||||
editMode: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => ({}),
|
||||
getFormDataExtra: () => ({}),
|
||||
fetchSlice: () => ({}),
|
||||
saveSlice: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
removeChart: () => ({}),
|
||||
updateDashboardLayout: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
addFilter: () => ({}),
|
||||
getFilters: () => ({}),
|
||||
clearFilter: () => ({}),
|
||||
removeFilter: () => ({}),
|
||||
};
|
||||
|
||||
class GridLayout extends React.Component {
|
||||
componentWillMount() {
|
||||
const layout = [];
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.props.dashboard.slices.forEach((slice, index) => {
|
||||
const sliceId = slice.slice_id;
|
||||
let pos = this.props.dashboard.posDict[sliceId];
|
||||
if (!pos) {
|
||||
pos = {
|
||||
col: (index * 4 + 1) % 12,
|
||||
row: Math.floor((index) / 3) * 4,
|
||||
size_x: 4,
|
||||
size_y: 4,
|
||||
};
|
||||
}
|
||||
|
||||
layout.push({
|
||||
i: String(sliceId),
|
||||
x: pos.col - 1,
|
||||
y: pos.row,
|
||||
w: pos.size_x,
|
||||
minW: 2,
|
||||
h: pos.size_y,
|
||||
});
|
||||
});
|
||||
|
||||
this.setState({
|
||||
layout,
|
||||
slices: this.props.dashboard.slices,
|
||||
});
|
||||
this.onResizeStop = this.onResizeStop.bind(this);
|
||||
this.onDragStop = this.onDragStop.bind(this);
|
||||
this.forceRefresh = this.forceRefresh.bind(this);
|
||||
this.removeSlice = this.removeSlice.bind(this);
|
||||
this.updateSliceName = this.props.dashboard.dash_edit_perm ?
|
||||
this.updateSliceName.bind(this) : null;
|
||||
}
|
||||
|
||||
onResizeStop(layout, oldItem, newItem) {
|
||||
const newSlice = this.props.dashboard.getSlice(newItem.i);
|
||||
if (oldItem.w !== newItem.w || oldItem.h !== newItem.h) {
|
||||
this.setState({ layout }, () => newSlice.resize());
|
||||
}
|
||||
this.props.dashboard.onChange();
|
||||
onResizeStop(layout) {
|
||||
this.props.updateDashboardLayout(layout);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
onDragStop(layout) {
|
||||
this.setState({ layout });
|
||||
this.props.dashboard.onChange();
|
||||
this.props.updateDashboardLayout(layout);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
removeSlice(sliceId) {
|
||||
$('[data-toggle=tooltip]').tooltip('hide');
|
||||
this.setState({
|
||||
layout: this.state.layout.filter(function (reactPos) {
|
||||
return reactPos.i !== String(sliceId);
|
||||
}),
|
||||
slices: this.state.slices.filter(function (slice) {
|
||||
return slice.slice_id !== sliceId;
|
||||
}),
|
||||
});
|
||||
this.props.dashboard.onChange();
|
||||
getWidgetId(slice) {
|
||||
return 'widget_' + slice.slice_id;
|
||||
}
|
||||
|
||||
getWidgetHeight(slice) {
|
||||
const widgetId = this.getWidgetId(slice);
|
||||
if (!widgetId || !this.refs[widgetId]) {
|
||||
return 400;
|
||||
}
|
||||
return this.refs[widgetId].offsetHeight;
|
||||
}
|
||||
|
||||
getWidgetWidth(slice) {
|
||||
const widgetId = this.getWidgetId(slice);
|
||||
if (!widgetId || !this.refs[widgetId]) {
|
||||
return 400;
|
||||
}
|
||||
return this.refs[widgetId].offsetWidth;
|
||||
}
|
||||
|
||||
findSliceIndexById(sliceId) {
|
||||
return this.props.dashboard.slices
|
||||
.map(slice => (slice.slice_id)).indexOf(sliceId);
|
||||
}
|
||||
|
||||
forceRefresh(sliceId) {
|
||||
return this.props.fetchSlice(this.props.charts['slice_' + sliceId], true);
|
||||
}
|
||||
|
||||
removeSlice(slice) {
|
||||
if (!slice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove slice dashbaord and charts
|
||||
this.props.removeSlice(slice);
|
||||
this.props.removeChart(this.props.charts['slice_' + slice.slice_id].chartKey);
|
||||
this.props.onChange();
|
||||
}
|
||||
|
||||
updateSliceName(sliceId, sliceName) {
|
||||
const index = this.state.slices.map(slice => (slice.slice_id)).indexOf(sliceId);
|
||||
const index = this.findSliceIndexById(sliceId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// update slice_name first
|
||||
const oldSlices = this.state.slices;
|
||||
const currentSlice = this.state.slices[index];
|
||||
const updated = Object.assign({},
|
||||
this.state.slices[index], { slice_name: sliceName });
|
||||
const updatedSlices = this.state.slices.slice();
|
||||
updatedSlices[index] = updated;
|
||||
this.setState({ slices: updatedSlices });
|
||||
const currentSlice = this.props.dashboard.slices[index];
|
||||
if (currentSlice.slice_name === sliceName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sliceParams = {};
|
||||
sliceParams.slice_id = currentSlice.slice_id;
|
||||
sliceParams.action = 'overwrite';
|
||||
sliceParams.slice_name = sliceName;
|
||||
const saveUrl = getExploreUrl(currentSlice.form_data, 'base', false, null, sliceParams);
|
||||
|
||||
$.ajax({
|
||||
url: saveUrl,
|
||||
type: 'GET',
|
||||
success: () => {
|
||||
notify.success('This slice name was saved successfully.');
|
||||
},
|
||||
error: () => {
|
||||
// if server-side reject the overwrite action,
|
||||
// revert to old state
|
||||
this.setState({ slices: oldSlices });
|
||||
notify.error('You don\'t have the rights to alter this slice');
|
||||
},
|
||||
});
|
||||
this.props.saveSlice(currentSlice, sliceName);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.state.layout.map(reactPos => ({
|
||||
slice_id: reactPos.i,
|
||||
col: reactPos.x + 1,
|
||||
row: reactPos.y,
|
||||
size_x: reactPos.w,
|
||||
size_y: reactPos.h,
|
||||
}));
|
||||
isExpanded(slice) {
|
||||
return this.props.dashboard.metadata.expanded_slices &&
|
||||
this.props.dashboard.metadata.expanded_slices[slice.slice_id];
|
||||
}
|
||||
|
||||
render() {
|
||||
const cells = this.props.dashboard.slices.map((slice) => {
|
||||
const chartKey = `slice_${slice.slice_id}`;
|
||||
const currentChart = this.props.charts[chartKey];
|
||||
const queryResponse = currentChart.queryResponse || {};
|
||||
return (
|
||||
<div
|
||||
id={'slice_' + slice.slice_id}
|
||||
key={slice.slice_id}
|
||||
data-slice-id={slice.slice_id}
|
||||
className={`widget ${slice.form_data.viz_type}`}
|
||||
ref={this.getWidgetId(slice)}
|
||||
>
|
||||
<GridCell
|
||||
slice={slice}
|
||||
chartKey={chartKey}
|
||||
datasource={this.props.datasources[slice.form_data.datasource]}
|
||||
filters={this.props.filters}
|
||||
formData={this.props.getFormDataExtra(slice)}
|
||||
timeout={this.props.timeout}
|
||||
widgetHeight={this.getWidgetHeight(slice)}
|
||||
widgetWidth={this.getWidgetWidth(slice)}
|
||||
exploreChartUrl={getExploreUrl(this.props.getFormDataExtra(slice))}
|
||||
exportCSVUrl={getExploreUrl(this.props.getFormDataExtra(slice), 'csv')}
|
||||
isExpanded={!!this.isExpanded(slice)}
|
||||
isLoading={currentChart.chartStatus === 'loading'}
|
||||
isCached={queryResponse.is_cached}
|
||||
cachedDttm={queryResponse.cached_dttm}
|
||||
toggleExpandSlice={this.props.toggleExpandSlice}
|
||||
forceRefresh={this.forceRefresh}
|
||||
removeSlice={this.removeSlice}
|
||||
updateSliceName={this.updateSliceName}
|
||||
addFilter={this.props.addFilter}
|
||||
getFilters={this.props.getFilters}
|
||||
clearFilter={this.props.clearFilter}
|
||||
removeFilter={this.props.removeFilter}
|
||||
editMode={this.props.editMode}
|
||||
annotationQuery={currentChart.annotationQuery}
|
||||
annotationError={currentChart.annotationError}
|
||||
/>
|
||||
</div>);
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveReactGridLayout
|
||||
className="layout"
|
||||
layouts={{ lg: this.state.layout }}
|
||||
onResizeStop={this.onResizeStop.bind(this)}
|
||||
onDragStop={this.onDragStop.bind(this)}
|
||||
layouts={{ lg: this.props.dashboard.layout }}
|
||||
onResizeStop={this.onResizeStop}
|
||||
onDragStop={this.onDragStop}
|
||||
cols={{ lg: 12, md: 12, sm: 10, xs: 8, xxs: 6 }}
|
||||
rowHeight={100}
|
||||
autoSize
|
||||
@@ -134,27 +183,13 @@ class GridLayout extends React.Component {
|
||||
useCSSTransforms
|
||||
draggableHandle=".drag"
|
||||
>
|
||||
{this.state.slices.map(slice => (
|
||||
<div
|
||||
id={'slice_' + slice.slice_id}
|
||||
key={slice.slice_id}
|
||||
data-slice-id={slice.slice_id}
|
||||
className={`widget ${slice.form_data.viz_type}`}
|
||||
>
|
||||
<SliceCell
|
||||
slice={slice}
|
||||
removeSlice={this.removeSlice.bind(this, slice.slice_id)}
|
||||
expandedSlices={this.props.dashboard.metadata.expanded_slices}
|
||||
updateSliceName={this.props.dashboard.dash_edit_perm ?
|
||||
this.updateSliceName.bind(this) : null}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{cells}
|
||||
</ResponsiveReactGridLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout.propTypes = propTypes;
|
||||
GridLayout.defaultProps = defaultProps;
|
||||
|
||||
export default GridLayout;
|
||||
|
||||
@@ -3,42 +3,108 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Controls from './Controls';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import Button from '../../components/Button';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object,
|
||||
};
|
||||
const defaultProps = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
fetchFaveStar: PropTypes.func,
|
||||
renderSlices: PropTypes.func,
|
||||
saveFaveStar: PropTypes.func,
|
||||
serialize: PropTypes.func,
|
||||
startPeriodicRender: PropTypes.func,
|
||||
updateDashboardTitle: PropTypes.func,
|
||||
editMode: PropTypes.bool.isRequired,
|
||||
setEditMode: PropTypes.func.isRequired,
|
||||
unsavedChanges: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
class Header extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
};
|
||||
this.handleSaveTitle = this.handleSaveTitle.bind(this);
|
||||
this.toggleEditMode = this.toggleEditMode.bind(this);
|
||||
}
|
||||
handleSaveTitle(title) {
|
||||
this.props.dashboard.updateDashboardTitle(title);
|
||||
this.props.updateDashboardTitle(title);
|
||||
}
|
||||
toggleEditMode() {
|
||||
this.props.setEditMode(!this.props.editMode);
|
||||
}
|
||||
renderUnsaved() {
|
||||
if (!this.props.unsavedChanges) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<InfoTooltipWithTrigger
|
||||
label="unsaved"
|
||||
tooltip={t('Unsaved changes')}
|
||||
icon="exclamation-triangle"
|
||||
className="text-danger m-r-5"
|
||||
placement="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderEditButton() {
|
||||
if (!this.props.dashboard.dash_save_perm) {
|
||||
return null;
|
||||
}
|
||||
const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
|
||||
return (
|
||||
<Button
|
||||
bsStyle="default"
|
||||
className="m-r-5"
|
||||
style={{ width: '150px' }}
|
||||
onClick={this.toggleEditMode}
|
||||
>
|
||||
{btnText}
|
||||
</Button>);
|
||||
}
|
||||
render() {
|
||||
const dashboard = this.props.dashboard;
|
||||
return (
|
||||
<div className="title">
|
||||
<div className="pull-left">
|
||||
<h1 className="outer-container">
|
||||
<h1 className="outer-container pull-left">
|
||||
<EditableTitle
|
||||
title={dashboard.dashboard_title}
|
||||
canEdit={dashboard.dash_save_perm}
|
||||
canEdit={dashboard.dash_save_perm && this.props.editMode}
|
||||
onSaveTitle={this.handleSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
showTooltip={this.props.editMode}
|
||||
/>
|
||||
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
|
||||
<span className="favstar m-r-5">
|
||||
<FaveStar
|
||||
itemId={dashboard.id}
|
||||
fetchFaveStar={this.props.fetchFaveStar}
|
||||
saveFaveStar={this.props.saveFaveStar}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
</span>
|
||||
{this.renderUnsaved()}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="pull-right" style={{ marginTop: '35px' }}>
|
||||
{!this.props.dashboard.standalone_mode &&
|
||||
<Controls dashboard={dashboard} />
|
||||
}
|
||||
{this.renderEditButton()}
|
||||
<Controls
|
||||
dashboard={dashboard}
|
||||
filters={this.props.filters}
|
||||
userId={this.props.userId}
|
||||
addSlicesToDashboard={this.props.addSlicesToDashboard}
|
||||
onSave={this.props.onSave}
|
||||
onChange={this.props.onChange}
|
||||
renderSlices={this.props.renderSlices}
|
||||
serialize={this.props.serialize}
|
||||
startPeriodicRender={this.props.startPeriodicRender}
|
||||
editMode={this.props.editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="clearfix" />
|
||||
</div>
|
||||
@@ -46,6 +112,5 @@ class Header extends React.PureComponent {
|
||||
}
|
||||
}
|
||||
Header.propTypes = propTypes;
|
||||
Header.defaultProps = defaultProps;
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -34,7 +34,7 @@ class RefreshIntervalModal extends React.PureComponent {
|
||||
return (
|
||||
<ModalTrigger
|
||||
triggerNode={this.props.triggerNode}
|
||||
isButton
|
||||
isMenuItem
|
||||
modalTitle={t('Refresh Interval')}
|
||||
modalBody={
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,9 @@ const propTypes = {
|
||||
css: PropTypes.string,
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
triggerNode: PropTypes.node.isRequired,
|
||||
filters: PropTypes.object.isRequired,
|
||||
serialize: PropTypes.func,
|
||||
onSave: PropTypes.func,
|
||||
};
|
||||
|
||||
class SaveModal extends React.PureComponent {
|
||||
@@ -45,8 +48,8 @@ class SaveModal extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
saveDashboardRequest(data, url, saveType) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const saveModal = this.modal;
|
||||
const onSaveDashboard = this.props.onSave;
|
||||
Object.assign(data, { css: this.props.css });
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
@@ -56,7 +59,7 @@ class SaveModal extends React.PureComponent {
|
||||
},
|
||||
success(resp) {
|
||||
saveModal.close();
|
||||
dashboard.onSave();
|
||||
onSaveDashboard();
|
||||
if (saveType === 'newDashboard') {
|
||||
window.location = `/superset/dashboard/${resp.id}/`;
|
||||
} else {
|
||||
@@ -72,21 +75,13 @@ class SaveModal extends React.PureComponent {
|
||||
}
|
||||
saveDashboard(saveType, newDashboardTitle) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const expandedSlices = {};
|
||||
$.each($('.slice_info'), function () {
|
||||
const widget = $(this).parents('.widget');
|
||||
const sliceDescription = widget.find('.slice_description');
|
||||
if (sliceDescription.is(':visible')) {
|
||||
expandedSlices[$(widget).attr('data-slice-id')] = true;
|
||||
}
|
||||
});
|
||||
const positions = dashboard.reactGridLayout.serialize();
|
||||
const positions = this.props.serialize();
|
||||
const data = {
|
||||
positions,
|
||||
css: this.state.css,
|
||||
expanded_slices: expandedSlices,
|
||||
expanded_slices: dashboard.metadata.expanded_slices || {},
|
||||
dashboard_title: dashboard.dashboard_title,
|
||||
default_filters: dashboard.readFilters(),
|
||||
default_filters: JSON.stringify(this.props.filters),
|
||||
duplicate_slices: this.state.duplicateSlices,
|
||||
};
|
||||
let url = null;
|
||||
@@ -111,6 +106,7 @@ class SaveModal extends React.PureComponent {
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={(modal) => { this.modal = modal; }}
|
||||
isMenuItem
|
||||
triggerNode={this.props.triggerNode}
|
||||
modalTitle={t('Save Dashboard')}
|
||||
modalBody={
|
||||
|
||||
@@ -11,6 +11,8 @@ require('react-bootstrap-table/css/react-bootstrap-table.css');
|
||||
const propTypes = {
|
||||
dashboard: PropTypes.object.isRequired,
|
||||
triggerNode: PropTypes.node.isRequired,
|
||||
userId: PropTypes.string.isRequired,
|
||||
addSlicesToDashboard: PropTypes.func,
|
||||
};
|
||||
|
||||
class SliceAdder extends React.Component {
|
||||
@@ -39,11 +41,13 @@ class SliceAdder extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.slicesRequest.abort();
|
||||
if (this.slicesRequest) {
|
||||
this.slicesRequest.abort();
|
||||
}
|
||||
}
|
||||
|
||||
onEnterModal() {
|
||||
const uri = '/sliceaddview/api/read?_flt_0_created_by=' + this.props.dashboard.curUserId;
|
||||
const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
|
||||
this.slicesRequest = $.ajax({
|
||||
url: uri,
|
||||
type: 'GET',
|
||||
@@ -53,6 +57,7 @@ class SliceAdder extends React.Component {
|
||||
id: slice.id,
|
||||
sliceName: slice.slice_name,
|
||||
vizType: slice.viz_type,
|
||||
datasourceLink: slice.datasource_link,
|
||||
modified: slice.modified,
|
||||
}));
|
||||
|
||||
@@ -65,14 +70,30 @@ class SliceAdder extends React.Component {
|
||||
error: (error) => {
|
||||
this.errored = true;
|
||||
this.setState({
|
||||
errorMsg: this.props.dashboard.getAjaxErrorMsg(error),
|
||||
errorMsg: t('Sorry, there was an error fetching slices to this dashboard: ') +
|
||||
this.getAjaxErrorMsg(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getAjaxErrorMsg(error) {
|
||||
const respJSON = error.responseJSON;
|
||||
return (respJSON && respJSON.message) ? respJSON.message :
|
||||
error.responseText;
|
||||
}
|
||||
|
||||
addSlices() {
|
||||
this.props.dashboard.addSlicesToDashboard(Object.keys(this.state.selectionMap));
|
||||
const adder = this;
|
||||
this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
|
||||
// if successful, page will be reloaded.
|
||||
.fail((error) => {
|
||||
adder.errored = true;
|
||||
adder.setState({
|
||||
errorMsg: t('Sorry, there was an error adding slices to this dashboard: ') +
|
||||
this.getAjaxErrorMsg(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleSlice(slice) {
|
||||
@@ -147,6 +168,14 @@ class SliceAdder extends React.Component {
|
||||
>
|
||||
{t('Viz')}
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn
|
||||
dataField="datasourceLink"
|
||||
dataSort
|
||||
// Will cause react-bootstrap-table to interpret the HTML returned
|
||||
dataFormat={datasourceLink => datasourceLink}
|
||||
>
|
||||
{t('Datasource')}
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn
|
||||
dataField="modified"
|
||||
dataSort
|
||||
@@ -175,9 +204,10 @@ class SliceAdder extends React.Component {
|
||||
triggerNode={this.props.triggerNode}
|
||||
tooltip={t('Add a new slice to the dashboard')}
|
||||
beforeOpen={this.onEnterModal.bind(this)}
|
||||
isButton
|
||||
isMenuItem
|
||||
modalBody={modalContent}
|
||||
bsSize="large"
|
||||
setModalAsTriggerChildren
|
||||
modalTitle={t('Add Slices to Dashboard')}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { t } from '../../locales';
|
||||
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
removeSlice: PropTypes.func.isRequired,
|
||||
updateSliceName: PropTypes.func,
|
||||
expandedSlices: PropTypes.object,
|
||||
};
|
||||
|
||||
const SliceCell = ({ expandedSlices, removeSlice, slice, updateSliceName }) => {
|
||||
const onSaveTitle = (newTitle) => {
|
||||
if (updateSliceName) {
|
||||
updateSliceName(slice.slice_id, newTitle);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="slice-cell" id={`${slice.slice_id}-cell`}>
|
||||
<div className="row chart-header">
|
||||
<div className="col-md-12">
|
||||
<div className="header">
|
||||
<EditableTitle
|
||||
title={slice.slice_name}
|
||||
canEdit={!!updateSliceName}
|
||||
onSaveTitle={onSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
/>
|
||||
</div>
|
||||
<div className="chart-controls">
|
||||
<div id={'controls_' + slice.slice_id} className="pull-right">
|
||||
<a title={t('Move chart')} data-toggle="tooltip">
|
||||
<i className="fa fa-arrows drag" />
|
||||
</a>
|
||||
<a className="refresh" title={t('Force refresh data')} data-toggle="tooltip">
|
||||
<i className="fa fa-repeat" />
|
||||
</a>
|
||||
{slice.description &&
|
||||
<a title={t('Toggle chart description')}>
|
||||
<i
|
||||
className="fa fa-info-circle slice_info"
|
||||
title={slice.description}
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
href={slice.edit_url}
|
||||
title={t('Edit chart')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-pencil" />
|
||||
</a>
|
||||
<a
|
||||
className="exportCSV"
|
||||
href={getExploreUrl(slice.form_data, 'csv')}
|
||||
title={t('Export CSV')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-table" />
|
||||
</a>
|
||||
<a
|
||||
className="exploreChart"
|
||||
href={getExploreUrl(slice.form_data)}
|
||||
title={t('Explore chart')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i className="fa fa-share" />
|
||||
</a>
|
||||
<a
|
||||
className="remove-chart"
|
||||
title={t('Remove chart from dashboard')}
|
||||
data-toggle="tooltip"
|
||||
>
|
||||
<i
|
||||
className="fa fa-close"
|
||||
onClick={() => { removeSlice(slice.slice_id); }}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="slice_description bs-callout bs-callout-default"
|
||||
style={
|
||||
expandedSlices &&
|
||||
expandedSlices[String(slice.slice_id)] ? {} : { display: 'none' }
|
||||
}
|
||||
dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
|
||||
/>
|
||||
<div className="row chart-container">
|
||||
<input type="hidden" value="false" />
|
||||
<div id={'token_' + slice.slice_id} className="token col-md-12">
|
||||
<img
|
||||
src="/static/assets/images/loading.gif"
|
||||
className="loading"
|
||||
alt="loading"
|
||||
/>
|
||||
<div
|
||||
id={'con_' + slice.slice_id}
|
||||
className={`slice_container ${slice.form_data.viz_type}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SliceCell.propTypes = propTypes;
|
||||
|
||||
export default SliceCell;
|
||||
172
superset/assets/javascripts/dashboard/components/SliceHeader.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
|
||||
import { t } from '../../locales';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
|
||||
const propTypes = {
|
||||
slice: PropTypes.object.isRequired,
|
||||
exploreChartUrl: PropTypes.string,
|
||||
exportCSVUrl: PropTypes.string,
|
||||
isExpanded: PropTypes.bool,
|
||||
isCached: PropTypes.bool,
|
||||
cachedDttm: PropTypes.string,
|
||||
formDataExtra: PropTypes.object,
|
||||
removeSlice: PropTypes.func,
|
||||
updateSliceName: PropTypes.func,
|
||||
toggleExpandSlice: PropTypes.func,
|
||||
forceRefresh: PropTypes.func,
|
||||
editMode: PropTypes.bool,
|
||||
annotationQuery: PropTypes.object,
|
||||
annotationError: PropTypes.object,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
forceRefresh: () => ({}),
|
||||
removeSlice: () => ({}),
|
||||
updateSliceName: () => ({}),
|
||||
toggleExpandSlice: () => ({}),
|
||||
editMode: false,
|
||||
};
|
||||
|
||||
class SliceHeader extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.onSaveTitle = this.onSaveTitle.bind(this);
|
||||
}
|
||||
|
||||
onSaveTitle(newTitle) {
|
||||
if (this.props.updateSliceName) {
|
||||
this.props.updateSliceName(this.props.slice.slice_id, newTitle);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const slice = this.props.slice;
|
||||
const isCached = this.props.isCached;
|
||||
const isExpanded = !!this.props.isExpanded;
|
||||
const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
|
||||
const refreshTooltip = isCached ?
|
||||
t('Served from data cached %s . Click to force refresh.', cachedWhen) :
|
||||
t('Force refresh data');
|
||||
const annoationsLoading = t('Annotation layers are still loading.');
|
||||
const annoationsError = t('One ore more annotation layers failed loading.');
|
||||
|
||||
return (
|
||||
<div className="row chart-header">
|
||||
<div className="col-md-12">
|
||||
<div className="header">
|
||||
<EditableTitle
|
||||
title={slice.slice_name}
|
||||
canEdit={!!this.props.updateSliceName && this.props.editMode}
|
||||
onSaveTitle={this.onSaveTitle}
|
||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
||||
/>
|
||||
{!!Object.values(this.props.annotationQuery || {}).length &&
|
||||
<TooltipWrapper
|
||||
label="annotations-loading"
|
||||
placement="top"
|
||||
tooltip={annoationsLoading}
|
||||
>
|
||||
<i className="fa fa-refresh warning" />
|
||||
</TooltipWrapper>
|
||||
}
|
||||
{!!Object.values(this.props.annotationError || {}).length &&
|
||||
<TooltipWrapper
|
||||
label="annoation-errors"
|
||||
placement="top"
|
||||
tooltip={annoationsError}
|
||||
>
|
||||
<i className="fa fa-exclamation-circle danger" />
|
||||
</TooltipWrapper>
|
||||
}
|
||||
</div>
|
||||
<div className="chart-controls">
|
||||
<div id={'controls_' + slice.slice_id} className="pull-right">
|
||||
{this.props.editMode &&
|
||||
<a>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="move"
|
||||
tooltip={t('Move chart')}
|
||||
>
|
||||
<i className="fa fa-arrows drag" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
className={`refresh ${isCached ? 'danger' : ''}`}
|
||||
onClick={() => (this.props.forceRefresh(slice.slice_id))}
|
||||
>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="refresh"
|
||||
tooltip={refreshTooltip}
|
||||
>
|
||||
<i className="fa fa-repeat" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
{slice.description &&
|
||||
<a onClick={() => this.props.toggleExpandSlice(slice, !isExpanded)}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="description"
|
||||
tooltip={t('Toggle chart description')}
|
||||
>
|
||||
<i className="fa fa-info-circle slice_info" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
}
|
||||
<a href={slice.edit_url} target="_blank">
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="edit"
|
||||
tooltip={t('Edit chart')}
|
||||
>
|
||||
<i className="fa fa-pencil" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a className="exportCSV" href={this.props.exportCSVUrl}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="exportCSV"
|
||||
tooltip={t('Export CSV')}
|
||||
>
|
||||
<i className="fa fa-table" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
<a className="exploreChart" href={this.props.exploreChartUrl} target="_blank">
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="exploreChart"
|
||||
tooltip={t('Explore chart')}
|
||||
>
|
||||
<i className="fa fa-share" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
{this.props.editMode &&
|
||||
<a className="remove-chart" onClick={() => (this.props.removeSlice(slice))}>
|
||||
<TooltipWrapper
|
||||
placement="top"
|
||||
label="close"
|
||||
tooltip={t('Remove chart from dashboard')}
|
||||
>
|
||||
<i className="fa fa-close" />
|
||||
</TooltipWrapper>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliceHeader.propTypes = propTypes;
|
||||
SliceHeader.defaultProps = defaultProps;
|
||||
|
||||
export default SliceHeader;
|
||||
29
superset/assets/javascripts/dashboard/index.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { initEnhancer } from '../reduxUtils';
|
||||
import { appSetup } from '../common';
|
||||
import { initJQueryAjax } from '../modules/utils';
|
||||
import DashboardContainer from './components/DashboardContainer';
|
||||
import rootReducer, { getInitialState } from './reducers';
|
||||
|
||||
appSetup();
|
||||
initJQueryAjax();
|
||||
|
||||
const appContainer = document.getElementById('app');
|
||||
const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
|
||||
const initState = Object.assign({}, getInitialState(bootstrapData));
|
||||
|
||||
const store = createStore(
|
||||
rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<DashboardContainer />
|
||||
</Provider>,
|
||||
appContainer,
|
||||
);
|
||||
|
||||
205
superset/assets/javascripts/dashboard/reducers.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import d3 from 'd3';
|
||||
import shortid from 'shortid';
|
||||
|
||||
import charts, { chart } from '../chart/chartReducer';
|
||||
import * as actions from './actions';
|
||||
import { getParam } from '../modules/utils';
|
||||
import { alterInArr, removeFromArr } from '../reduxUtils';
|
||||
import { applyDefaultFormData } from '../explore/stores/store';
|
||||
import { getColorFromScheme } from '../modules/colors';
|
||||
|
||||
export function getInitialState(bootstrapData) {
|
||||
const { user_id, datasources, common } = bootstrapData;
|
||||
delete common.locale;
|
||||
delete common.language_pack;
|
||||
|
||||
const dashboard = { ...bootstrapData.dashboard_data };
|
||||
let filters = {};
|
||||
try {
|
||||
// allow request parameter overwrite dashboard metadata
|
||||
filters = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
// Priming the color palette with user's label-color mapping provided in
|
||||
// the dashboard's JSON metadata
|
||||
if (dashboard.metadata && dashboard.metadata.label_colors) {
|
||||
const colorMap = dashboard.metadata.label_colors;
|
||||
for (const label in colorMap) {
|
||||
getColorFromScheme(label, null, colorMap[label]);
|
||||
}
|
||||
}
|
||||
|
||||
dashboard.posDict = {};
|
||||
dashboard.layout = [];
|
||||
if (dashboard.position_json) {
|
||||
dashboard.position_json.forEach((position) => {
|
||||
dashboard.posDict[position.slice_id] = position;
|
||||
});
|
||||
}
|
||||
dashboard.slices.forEach((slice, index) => {
|
||||
const sliceId = slice.slice_id;
|
||||
let pos = dashboard.posDict[sliceId];
|
||||
if (!pos) {
|
||||
pos = {
|
||||
col: (index * 4 + 1) % 12,
|
||||
row: Math.floor((index) / 3) * 4,
|
||||
size_x: 4,
|
||||
size_y: 4,
|
||||
};
|
||||
}
|
||||
|
||||
dashboard.layout.push({
|
||||
i: String(sliceId),
|
||||
x: pos.col - 1,
|
||||
y: pos.row,
|
||||
w: pos.size_x,
|
||||
minW: 2,
|
||||
h: pos.size_y,
|
||||
});
|
||||
});
|
||||
|
||||
// will use charts action/reducers to handle chart render
|
||||
const initCharts = {};
|
||||
dashboard.slices.forEach((slice) => {
|
||||
const chartKey = 'slice_' + slice.slice_id;
|
||||
initCharts[chartKey] = { ...chart,
|
||||
chartKey,
|
||||
slice_id: slice.slice_id,
|
||||
form_data: slice.form_data,
|
||||
formData: applyDefaultFormData(slice.form_data),
|
||||
};
|
||||
});
|
||||
|
||||
// also need to add formData for dashboard.slices
|
||||
dashboard.slices = dashboard.slices.map(slice =>
|
||||
({ ...slice, formData: applyDefaultFormData(slice.form_data) }),
|
||||
);
|
||||
|
||||
return {
|
||||
charts: initCharts,
|
||||
dashboard: { filters, dashboard, userId: user_id, datasources, common, editMode: false },
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboard = function (state = {}, action) {
|
||||
const actionHandlers = {
|
||||
[actions.UPDATE_DASHBOARD_TITLE]() {
|
||||
const newDashboard = { ...state.dashboard, dashboard_title: action.title };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
[actions.UPDATE_DASHBOARD_LAYOUT]() {
|
||||
const newDashboard = { ...state.dashboard, layout: action.layout };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
[actions.REMOVE_SLICE]() {
|
||||
const key = String(action.slice.slice_id);
|
||||
const newLayout = state.dashboard.layout.filter(reactPos => (reactPos.i !== key));
|
||||
const newDashboard = removeFromArr(state.dashboard, 'slices', action.slice, 'slice_id');
|
||||
// if this slice is a filter
|
||||
const newFilter = { ...state.filters };
|
||||
let refresh = false;
|
||||
if (state.filters[key]) {
|
||||
delete newFilter[key];
|
||||
refresh = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
dashboard: { ...newDashboard, layout: newLayout },
|
||||
filters: newFilter,
|
||||
refresh,
|
||||
};
|
||||
},
|
||||
[actions.TOGGLE_FAVE_STAR]() {
|
||||
return { ...state, isStarred: action.isStarred };
|
||||
},
|
||||
[actions.SET_EDIT_MODE]() {
|
||||
return { ...state, editMode: action.editMode };
|
||||
},
|
||||
[actions.TOGGLE_EXPAND_SLICE]() {
|
||||
const updatedExpandedSlices = { ...state.dashboard.metadata.expanded_slices };
|
||||
const sliceId = action.slice.slice_id;
|
||||
if (action.isExpanded) {
|
||||
updatedExpandedSlices[sliceId] = true;
|
||||
} else {
|
||||
delete updatedExpandedSlices[sliceId];
|
||||
}
|
||||
const metadata = { ...state.dashboard.metadata, expanded_slices: updatedExpandedSlices };
|
||||
const newDashboard = { ...state.dashboard, metadata };
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
|
||||
// filters
|
||||
[actions.ADD_FILTER]() {
|
||||
const selectedSlice = state.dashboard.slices
|
||||
.find(slice => (slice.slice_id === action.sliceId));
|
||||
if (!selectedSlice) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let filters = state.filters;
|
||||
const { sliceId, col, vals, merge, refresh } = action;
|
||||
const filterKeys = ['__from', '__to', '__time_col',
|
||||
'__time_grain', '__time_origin', '__granularity'];
|
||||
if (filterKeys.indexOf(col) >= 0 ||
|
||||
selectedSlice.formData.groupby.indexOf(col) !== -1) {
|
||||
let newFilter = {};
|
||||
if (!(sliceId in filters)) {
|
||||
// Straight up set the filters if none existed for the slice
|
||||
newFilter = { [col]: vals };
|
||||
} else if (filters[sliceId] && !(col in filters[sliceId]) || !merge) {
|
||||
newFilter = { ...filters[sliceId], [col]: vals };
|
||||
// d3.merge pass in array of arrays while some value form filter components
|
||||
// from and to filter box require string to be process and return
|
||||
} else if (filters[sliceId][col] instanceof Array) {
|
||||
newFilter[col] = d3.merge([filters[sliceId][col], vals]);
|
||||
} else {
|
||||
newFilter[col] = d3.merge([[filters[sliceId][col]], vals])[0] || '';
|
||||
}
|
||||
filters = { ...filters, [sliceId]: newFilter };
|
||||
}
|
||||
return { ...state, filters, refresh };
|
||||
},
|
||||
[actions.CLEAR_FILTER]() {
|
||||
const newFilters = { ...state.filters };
|
||||
delete newFilters[action.sliceId];
|
||||
return { ...state, filter: newFilters, refresh: true };
|
||||
},
|
||||
[actions.REMOVE_FILTER]() {
|
||||
const { sliceId, col, vals, refresh } = action;
|
||||
const excluded = new Set(vals);
|
||||
const valFilter = val => !excluded.has(val);
|
||||
|
||||
let filters = state.filters;
|
||||
// Have to be careful not to modify the dashboard state so that
|
||||
// the render actually triggers
|
||||
if (sliceId in state.filters && col in state.filters[sliceId]) {
|
||||
const newFilter = filters[sliceId][col].filter(valFilter);
|
||||
filters = { ...filters, [sliceId]: newFilter };
|
||||
}
|
||||
return { ...state, filters, refresh };
|
||||
},
|
||||
|
||||
// slice reducer
|
||||
[actions.UPDATE_SLICE_NAME]() {
|
||||
const newDashboard = alterInArr(
|
||||
state.dashboard, 'slices',
|
||||
action.slice, { slice_name: action.sliceName },
|
||||
'slice_id');
|
||||
return { ...state, dashboard: newDashboard };
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
return actionHandlers[action.type]();
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
charts,
|
||||
dashboard,
|
||||
impressionId: () => (shortid.generate()),
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import { triggerQuery } from './exploreActions';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||
export function chartUpdateStarted(queryRequest, latestQueryFormData) {
|
||||
return { type: CHART_UPDATE_STARTED, queryRequest, latestQueryFormData };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
||||
export function chartUpdateSucceeded(queryResponse) {
|
||||
return { type: CHART_UPDATE_SUCCEEDED, queryResponse };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
||||
export function chartUpdateStopped(queryRequest) {
|
||||
if (queryRequest) {
|
||||
queryRequest.abort();
|
||||
}
|
||||
return { type: CHART_UPDATE_STOPPED };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_TIMEOUT = 'CHART_UPDATE_TIMEOUT';
|
||||
export function chartUpdateTimeout(statusText, timeout) {
|
||||
return { type: CHART_UPDATE_TIMEOUT, statusText, timeout };
|
||||
}
|
||||
|
||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
||||
export function chartUpdateFailed(queryResponse) {
|
||||
return { type: CHART_UPDATE_FAILED, queryResponse };
|
||||
}
|
||||
|
||||
export const UPDATE_CHART_STATUS = 'UPDATE_CHART_STATUS';
|
||||
export function updateChartStatus(status) {
|
||||
return { type: UPDATE_CHART_STATUS, status };
|
||||
}
|
||||
|
||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
||||
export function chartRenderingFailed(error) {
|
||||
return { type: CHART_RENDERING_FAILED, error };
|
||||
}
|
||||
|
||||
export const REMOVE_CHART_ALERT = 'REMOVE_CHART_ALERT';
|
||||
export function removeChartAlert() {
|
||||
return { type: REMOVE_CHART_ALERT };
|
||||
}
|
||||
|
||||
export const RUN_QUERY = 'RUN_QUERY';
|
||||
export function runQuery(formData, force = false, timeout = 60) {
|
||||
return function (dispatch, getState) {
|
||||
const { explore } = getState();
|
||||
const lastQueryFormData = getFormDataFromControls(explore.controls);
|
||||
const url = getExploreUrl(formData, 'json', force);
|
||||
const queryRequest = $.ajax({
|
||||
url,
|
||||
dataType: 'json',
|
||||
success(queryResponse) {
|
||||
dispatch(chartUpdateSucceeded(queryResponse));
|
||||
},
|
||||
error(err) {
|
||||
if (err.statusText === 'timeout') {
|
||||
dispatch(chartUpdateTimeout(err.statusText, timeout));
|
||||
} else if (err.statusText !== 'abort') {
|
||||
dispatch(chartUpdateFailed(err.responseJSON));
|
||||
}
|
||||
},
|
||||
timeout: timeout * 1000,
|
||||
});
|
||||
dispatch(chartUpdateStarted(queryRequest, lastQueryFormData));
|
||||
dispatch(triggerQuery(false));
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint camelcase: 0 */
|
||||
import { triggerQuery } from '../../chart/chartAction';
|
||||
|
||||
const $ = window.$ = require('jquery');
|
||||
|
||||
@@ -54,11 +55,6 @@ export function resetControls() {
|
||||
return { type: RESET_FIELDS };
|
||||
}
|
||||
|
||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
||||
export function triggerQuery(value = true) {
|
||||
return { type: TRIGGER_QUERY, value };
|
||||
}
|
||||
|
||||
export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) {
|
||||
return function (dispatch) {
|
||||
dispatch(fetchDatasourceStarted());
|
||||
@@ -146,11 +142,6 @@ export function updateChartTitle(slice_name) {
|
||||
return { type: UPDATE_CHART_TITLE, slice_name };
|
||||
}
|
||||
|
||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
||||
export function renderTriggered() {
|
||||
return { type: RENDER_TRIGGERED };
|
||||
}
|
||||
|
||||
export const CREATE_NEW_SLICE = 'CREATE_NEW_SLICE';
|
||||
export function createNewSlice(can_add, can_download, can_overwrite, slice, form_data) {
|
||||
return { type: CREATE_NEW_SLICE, can_add, can_download, can_overwrite, slice, form_data };
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Mustache from 'mustache';
|
||||
import { connect } from 'react-redux';
|
||||
import { Alert, Collapse, Panel } from 'react-bootstrap';
|
||||
import visMap from '../../../visualizations/main';
|
||||
import { d3format } from '../../modules/utils';
|
||||
import ExploreActionButtons from './ExploreActionButtons';
|
||||
import EditableTitle from '../../components/EditableTitle';
|
||||
import FaveStar from '../../components/FaveStar';
|
||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||
import Timer from '../../components/Timer';
|
||||
import { getExploreUrl } from '../exploreUtils';
|
||||
import { getFormDataFromControls } from '../stores/store';
|
||||
import CachedLabel from '../../components/CachedLabel';
|
||||
import { t } from '../../locales';
|
||||
|
||||
const CHART_STATUS_MAP = {
|
||||
failed: 'danger',
|
||||
loading: 'warning',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
alert: PropTypes.string,
|
||||
can_overwrite: PropTypes.bool.isRequired,
|
||||
can_download: PropTypes.bool.isRequired,
|
||||
chartStatus: PropTypes.string,
|
||||
chartUpdateEndTime: PropTypes.number,
|
||||
chartUpdateStartTime: PropTypes.number.isRequired,
|
||||
column_formats: PropTypes.object,
|
||||
containerId: PropTypes.string.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
width: PropTypes.string.isRequired,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
table_name: PropTypes.string,
|
||||
viz_type: PropTypes.string.isRequired,
|
||||
formData: PropTypes.object,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
queryResponse: PropTypes.object,
|
||||
triggerRender: PropTypes.bool,
|
||||
standalone: PropTypes.bool,
|
||||
datasourceType: PropTypes.string,
|
||||
datasourceId: PropTypes.number,
|
||||
timeout: PropTypes.number,
|
||||
};
|
||||
|
||||
class ChartContainer extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selector: `#${props.containerId}`,
|
||||
showStackTrace: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
this.props.queryResponse &&
|
||||
(
|
||||
prevProps.queryResponse !== this.props.queryResponse ||
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.width !== this.props.width ||
|
||||
this.props.triggerRender
|
||||
) && !this.props.queryResponse.error
|
||||
&& this.props.chartStatus !== 'failed'
|
||||
&& this.props.chartStatus !== 'stopped'
|
||||
&& this.props.chartStatus !== 'loading'
|
||||
) {
|
||||
this.renderViz();
|
||||
}
|
||||
}
|
||||
|
||||
getMockedSliceObject() {
|
||||
const props = this.props;
|
||||
const getHeight = () => {
|
||||
const headerHeight = props.standalone ? 0 : 100;
|
||||
return parseInt(props.height, 10) - headerHeight;
|
||||
};
|
||||
return {
|
||||
viewSqlQuery: props.queryResponse.query,
|
||||
containerId: props.containerId,
|
||||
datasource: props.datasource,
|
||||
selector: this.state.selector,
|
||||
formData: props.formData,
|
||||
container: {
|
||||
html: (data) => {
|
||||
// this should be a callback to clear the contents of the slice container
|
||||
$(this.state.selector).html(data);
|
||||
},
|
||||
css: (property, value) => {
|
||||
$(this.state.selector).css(property, value);
|
||||
},
|
||||
height: getHeight,
|
||||
show: () => { },
|
||||
get: n => ($(this.state.selector).get(n)),
|
||||
find: classname => ($(this.state.selector).find(classname)),
|
||||
},
|
||||
|
||||
width: () => this.chartContainerRef.getBoundingClientRect().width,
|
||||
|
||||
height: getHeight,
|
||||
|
||||
render_template: (s) => {
|
||||
const context = {
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
};
|
||||
return Mustache.render(s, context);
|
||||
},
|
||||
|
||||
setFilter: () => {},
|
||||
|
||||
getFilters: () => (
|
||||
// return filter objects from viz.formData
|
||||
{}
|
||||
),
|
||||
|
||||
addFilter: () => {},
|
||||
|
||||
removeFilter: () => {},
|
||||
|
||||
done: () => {},
|
||||
clearError: () => {
|
||||
// no need to do anything here since Alert is closable
|
||||
// query button will also remove Alert
|
||||
},
|
||||
error() {},
|
||||
|
||||
d3format: (col, number) => {
|
||||
// mock d3format function in Slice object in superset.js
|
||||
const format = props.column_formats[col];
|
||||
return d3format(format, number);
|
||||
},
|
||||
|
||||
data: {
|
||||
csv_endpoint: getExploreUrl(props.formData, 'csv'),
|
||||
json_endpoint: getExploreUrl(props.formData, 'json'),
|
||||
standalone_endpoint: getExploreUrl(props.formData, 'standalone'),
|
||||
},
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
removeAlert() {
|
||||
this.props.actions.removeChartAlert();
|
||||
}
|
||||
|
||||
runQuery() {
|
||||
this.props.actions.runQuery(this.props.formData, true, this.props.timeout);
|
||||
}
|
||||
|
||||
updateChartTitleOrSaveSlice(newTitle) {
|
||||
const isNewSlice = !this.props.slice;
|
||||
const params = {
|
||||
slice_name: newTitle,
|
||||
action: isNewSlice ? 'saveas' : 'overwrite',
|
||||
};
|
||||
const saveUrl = getExploreUrl(this.props.formData, 'base', false, null, params);
|
||||
this.props.actions.saveSlice(saveUrl)
|
||||
.then((data) => {
|
||||
if (isNewSlice) {
|
||||
this.props.actions.createNewSlice(
|
||||
data.can_add, data.can_download, data.can_overwrite,
|
||||
data.slice, data.form_data);
|
||||
} else {
|
||||
this.props.actions.updateChartTitle(newTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderChartTitle() {
|
||||
let title;
|
||||
if (this.props.slice) {
|
||||
title = this.props.slice.slice_name;
|
||||
} else {
|
||||
title = t('%s - untitled', this.props.table_name);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
renderViz() {
|
||||
this.props.actions.renderTriggered();
|
||||
const mockSlice = this.getMockedSliceObject();
|
||||
this.setState({ mockSlice });
|
||||
const viz = visMap[this.props.viz_type];
|
||||
try {
|
||||
viz(mockSlice, this.props.queryResponse, this.props.actions.setControlValue);
|
||||
} catch (e) {
|
||||
this.props.actions.chartRenderingFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
renderAlert() {
|
||||
/* eslint-disable react/no-danger */
|
||||
const msg = (
|
||||
<div>
|
||||
<i
|
||||
className="fa fa-close pull-right"
|
||||
onClick={this.removeAlert.bind(this)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{ __html: this.props.alert }}
|
||||
/>
|
||||
</div>);
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
bsStyle="warning"
|
||||
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
|
||||
>
|
||||
{msg}
|
||||
</Alert>
|
||||
{this.props.queryResponse && this.props.queryResponse.stacktrace &&
|
||||
<Collapse in={this.state.showStackTrace}>
|
||||
<pre>
|
||||
{this.props.queryResponse.stacktrace}
|
||||
</pre>
|
||||
</Collapse>
|
||||
}
|
||||
</div>);
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
if (this.props.alert) {
|
||||
return this.renderAlert();
|
||||
}
|
||||
const loading = this.props.chartStatus === 'loading';
|
||||
return (
|
||||
<div>
|
||||
{loading &&
|
||||
<img
|
||||
alt="loading"
|
||||
width="25"
|
||||
src="/static/assets/images/loading.gif"
|
||||
style={{ position: 'absolute' }}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
id={this.props.containerId}
|
||||
ref={(ref) => { this.chartContainerRef = ref; }}
|
||||
className={this.props.viz_type}
|
||||
style={{
|
||||
opacity: loading ? '0.25' : '1',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.standalone) {
|
||||
// dom manipulation hack to get rid of the boostrap theme's body background
|
||||
$('body').addClass('background-transparent');
|
||||
return this.renderChart();
|
||||
}
|
||||
const queryResponse = this.props.queryResponse;
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<Panel
|
||||
style={{ height: this.props.height }}
|
||||
header={
|
||||
<div
|
||||
id="slice-header"
|
||||
className="clearfix panel-title-large"
|
||||
>
|
||||
<EditableTitle
|
||||
title={this.renderChartTitle()}
|
||||
canEdit={!this.props.slice || this.props.can_overwrite}
|
||||
onSaveTitle={this.updateChartTitleOrSaveSlice.bind(this)}
|
||||
/>
|
||||
|
||||
{this.props.slice &&
|
||||
<span>
|
||||
<FaveStar
|
||||
sliceId={this.props.slice.slice_id}
|
||||
actions={this.props.actions}
|
||||
isStarred={this.props.isStarred}
|
||||
/>
|
||||
|
||||
<TooltipWrapper
|
||||
label="edit-desc"
|
||||
tooltip={t('Edit slice properties')}
|
||||
>
|
||||
<a
|
||||
className="edit-desc-icon"
|
||||
href={`/slicemodelview/edit/${this.props.slice.slice_id}`}
|
||||
>
|
||||
<i className="fa fa-edit" />
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div className="pull-right">
|
||||
{this.props.chartStatus === 'success' &&
|
||||
this.props.queryResponse &&
|
||||
this.props.queryResponse.is_cached &&
|
||||
<CachedLabel
|
||||
onClick={this.runQuery.bind(this)}
|
||||
cachedTimestamp={queryResponse.cached_dttm}
|
||||
/>
|
||||
}
|
||||
<Timer
|
||||
startTime={this.props.chartUpdateStartTime}
|
||||
endTime={this.props.chartUpdateEndTime}
|
||||
isRunning={this.props.chartStatus === 'loading'}
|
||||
status={CHART_STATUS_MAP[this.props.chartStatus]}
|
||||
style={{ fontSize: '10px', marginRight: '5px' }}
|
||||
/>
|
||||
<ExploreActionButtons
|
||||
slice={this.state.mockSlice}
|
||||
canDownload={this.props.can_download}
|
||||
chartStatus={this.props.chartStatus}
|
||||
queryResponse={queryResponse}
|
||||
queryEndpoint={getExploreUrl(this.props.latestQueryFormData, 'query')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{this.renderChart()}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChartContainer.propTypes = propTypes;
|
||||
|
||||
function mapStateToProps({ explore, chart }) {
|
||||
const formData = getFormDataFromControls(explore.controls);
|
||||
return {
|
||||
alert: chart.chartAlert,
|
||||
can_overwrite: !!explore.can_overwrite,
|
||||
can_download: !!explore.can_download,
|
||||
datasource: explore.datasource,
|
||||
column_formats: explore.datasource ? explore.datasource.column_formats : null,
|
||||
containerId: explore.slice ? `slice-container-${explore.slice.slice_id}` : 'slice-container',
|
||||
formData,
|
||||
isStarred: explore.isStarred,
|
||||
slice: explore.slice,
|
||||
standalone: explore.standalone,
|
||||
table_name: formData.datasource_name,
|
||||
viz_type: formData.viz_type,
|
||||
triggerRender: explore.triggerRender,
|
||||
datasourceType: explore.datasource.type,
|
||||
datasourceId: explore.datasource_id,
|
||||
chartStatus: chart.chartStatus,
|
||||
chartUpdateEndTime: chart.chartUpdateEndTime,
|
||||
chartUpdateStartTime: chart.chartUpdateStartTime,
|
||||
latestQueryFormData: chart.latestQueryFormData,
|
||||
queryResponse: chart.queryResponse,
|
||||
timeout: explore.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, () => ({}))(ChartContainer);
|
||||
@@ -13,6 +13,7 @@ const propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
choices: PropTypes.arrayOf(PropTypes.array),
|
||||
description: PropTypes.string,
|
||||
tooltipOnClick: PropTypes.func,
|
||||
places: PropTypes.number,
|
||||
validators: PropTypes.array,
|
||||
validationErrors: PropTypes.array,
|
||||
@@ -21,8 +22,10 @@ const propTypes = {
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.object,
|
||||
PropTypes.bool,
|
||||
PropTypes.array]),
|
||||
PropTypes.array,
|
||||
PropTypes.func]),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
|
||||