Compare commits

...

172 Commits

Author SHA1 Message Date
Arik Fraimovich
43a66fae82 Merge branch 'master' of github.com:getredash/redash 2018-04-16 11:21:24 +03:00
Arik Fraimovich
ed739e1292 Update version 2018-04-16 11:21:14 +03:00
Arik Fraimovich
212c7eed46 Merge pull request #2457 from kyoshidajp/fix_td_syntax_error
Fix syntax error
2018-04-15 10:45:46 +03:00
Katsuhiko YOSHIDA
ce50042407 Fix syntax error 2018-04-14 09:46:05 +09:00
Arik Fraimovich
e17e36f9e4 Update CHANGELOG.md 2018-04-12 15:40:32 +03:00
Arik Fraimovich
0bc570d741 Merge pull request #2407 from alon710/master
adds mattermost destination
2018-04-12 15:17:58 +03:00
Arik Fraimovich
7465c74392 Merge pull request #2428 from toru-takahashi/master
Improve a query failure message for Treasure Data Runner
2018-04-12 15:14:24 +03:00
Arik Fraimovich
a8a91109ee Merge pull request #2452 from kravets-levko/bug/firefox-widget-height-continuously-increases
Firefox: widget height continuously increases
2018-04-11 22:29:54 +03:00
Arik Fraimovich
add60c2552 Merge pull request #2451 from kravets-levko/bug/page-layout-lost
Wrong page layout if route change was cancelled
2018-04-11 22:29:26 +03:00
Levko Kravets
4dc8826beb Firefox, dashboard, auto-height feature: in some cases (loader shown and widget has params, but possibly other cases) widget start continuously growing 2018-04-10 21:08:27 +03:00
Levko Kravets
d35bbdb257 getredash/redash#2401 Wrong page layout if route change cancelled 2018-04-10 15:42:05 +03:00
Arik Fraimovich
8636c3310d Merge pull request #2449 from getredash/botocore-update
Update botocore version.
2018-04-09 23:02:13 +03:00
Arik Fraimovich
eddd9419a4 Merge pull request #2450 from getredash/map-names
Update map visualizations names.
2018-04-09 22:57:40 +03:00
Arik Fraimovich
2d08314982 Update Choropleth visualization name. 2018-04-09 22:57:22 +03:00
Arik Fraimovich
28d69b0c60 Update map visualizations names. 2018-04-09 22:56:10 +03:00
Arik Fraimovich
7f76400550 Update botocore version.
Fixes #2400.
2018-04-09 22:55:20 +03:00
Arik Fraimovich
f551b348a7 Merge pull request #2447 from getredash/patches
Fixes to new dashboard layout engine
2018-04-09 09:47:43 +03:00
Toru Takahashi
b1567f4d8d Add safe guarding with .get() 2018-04-09 01:29:18 +09:00
Arik Fraimovich
d18c94a587 Merge pull request #2420 from fmy/patch-1
use MongoClient for ReplicaSet
2018-04-08 15:43:56 +03:00
Arik Fraimovich
f75c142981 Merge pull request #2434 from tonyjiangh/fix/funnel_typo
Fix funnel setting page typo
2018-04-08 15:39:09 +03:00
Arik Fraimovich
0959281a01 Merge pull request #2443 from deecay/choropleth-fullscreen
Choropleth: Add fullscreen control
2018-04-08 15:38:07 +03:00
Arik Fraimovich
96a0a512f3 Merge pull request #2446 from getredash/patches
Update pymongo version to support newer MongoDB versions
2018-04-08 15:33:06 +03:00
Levko Kravets
9899abfe6a Fix: some legacy dashboards got their widgets misplaced when using auto height. 2018-04-08 15:32:24 +03:00
Arik Fraimovich
d02386488c Fix: dashboard refresh was broken due to introduction of track by to ng-repeat. 2018-04-08 15:32:14 +03:00
Arik Fraimovich
5f25bc480c Update pymongo version to support newer MongoDB versions 2018-04-08 15:26:35 +03:00
deecay
07b5003c6f Map: Add fullscreen control 2018-04-07 14:37:44 +09:00
Arik Fraimovich
8aba5db862 Merge pull request #2441 from tnetennba3/pronoun-fix
Changed "his" to "their"
2018-04-06 21:22:44 +03:00
deecay
b3ee25079e Choropleth: Add fullscreen control 2018-04-06 13:08:28 -04:00
tnetennba3
85179fd07b Changed "his" to "their" 2018-04-05 16:27:20 +01:00
Hao Jiang
390360cc4e Fix funnel setting page typo 2018-04-03 08:11:53 +09:00
Toru Takahashi
7edd5b9731 Improve a query failure message for Treasure Data Runner 2018-04-01 15:45:28 +09:00
Fumiya Karasawa
c681a50b19 use MongoClient for ReplicaSet
MongoReplicaSetClient is deprecated and will be removed in future.

See http://api.mongodb.com/python/current/api/pymongo/mongo_replica_set_client.html
```
MongoReplicaSetClient will be removed in a future version of PyMongo.

Changed in version 3.0: MongoClient is now the one and only client class for a standalone server, mongos, or replica set. It includes the functionality that had been split into MongoReplicaSetClient: it can connect to a replica set, discover all its members, and monitor the set for stepdowns, elections, and reconfigs.
```
2018-03-29 17:46:27 +09:00
Arik Fraimovich
8df2391a77 Merge pull request #2376 from valentin2105/master
Fix docker-entrypoint broke for other name than "postgres"
2018-03-27 09:16:14 +03:00
Valentin Ouvrard
0982e56ed0 fix entrypoint create_table() func. 2018-03-26 10:01:58 +11:00
Arik Fraimovich
0cb995bb35 Merge pull request #2413 from getredash/fix-bq
Fix: (BigQuery) UDF URI was used even if empty
2018-03-25 14:39:30 +03:00
Arik Fraimovich
d34d58bf33 Merge pull request #2374 from kravets-levko/feature/choropleth
Choropleth visualization
2018-03-25 14:34:45 +03:00
Arik Fraimovich
c19ff41392 Merge pull request #2406 from kravets-levko/feature/dashboard-gridstack
Replace dashboard engine with gridstack
2018-03-25 14:27:36 +03:00
Arik Fraimovich
abb6e56570 Fix: UDF URI was used even if empty 2018-03-25 14:22:36 +03:00
Arik Fraimovich
a7bba81969 Merge pull request #2412 from deecay/box-hover-fix
Fix: Box plot hover
2018-03-24 21:39:05 +03:00
deecay
6356a75478 Fix: Box plot hover 2018-03-24 20:57:04 +09:00
Levko Kravets
61ef5f9a02 gridstack: add comments, exclude lodash and moment locales from bundle 2018-03-23 19:50:55 +02:00
Levko Kravets
2fbf8926c4 gridstack: optimizations and bugfixes 2018-03-23 19:50:55 +02:00
Levko Kravets
ce9e3fcb35 Replace dashboard engine with gridstack 2018-03-23 19:50:28 +02:00
Arik Fraimovich
ffab6d5ec9 Merge pull request #2411 from getredash/patches
Multiple V4 regression fixes
2018-03-23 19:34:07 +03:00
Levko Kravets
be9bcaeb3d Multiple fixes:
* Fix: line chart with category x-axis: when some values missing, wrong hints displayed on hover
* Fix: second Y-axis not displayed when stacking enabled
* Set of dashboard improvements and bug fixes
    - set minimal height of widgets to 1 (was 4)
    - bug: for some widgets auto-height wasn't calculated properly (sometimess too small, sometimes too large)
    - bug: for small widgets, top-right menu was cut to widgets bounds
    - bug: with opened top-right menu widgets with auto-height started "dancing"
    - bug: at some point auto-height feature was disabling by itself (in fact - it depends on `angular-grindter`s internal processes)
    - fix: widget with empty contents had extra 40px of white space (paddings of container)
* Add scrollbars to pivot table widgets
* Fix: 100% CPU loading caused page lags
2018-03-23 19:25:21 +03:00
Arik Fraimovich
d140e0418f Fix: dashboard was reloading when clicking on refresh. 2018-03-23 19:22:23 +03:00
Arik Fraimovich
6685cb9e21 Load dashboard refresh rate from URL. 2018-03-23 19:22:06 +03:00
Arik Fraimovich
2f24cff33c Plotly: increase Y value accuracy. 2018-03-23 19:20:28 +03:00
Arik Fraimovich
193a6cce3f Fix: only try merging query object if it exists. 2018-03-23 19:18:28 +03:00
Arik Fraimovich
17951504f0 Change: apply time limit to alert status checking task. 2018-03-23 19:17:57 +03:00
Arik Fraimovich
ccffe70359 MSSQL Fix: UUID fields were detected as booleans. 2018-03-23 19:17:13 +03:00
Arik Fraimovich
503d6cecd0 DynamoDB fix: always return counter as a number rather than string. 2018-03-23 19:16:54 +03:00
Arik Fraimovich
6fbe06d262 Report Celery queue size. 2018-03-23 19:16:24 +03:00
Arik Fraimovich
2394f3fbe5 Add events property to Organization model. 2018-03-23 19:16:14 +03:00
Arik Fraimovich
cb815f3c8e Render safe HTML by default in tables to remain backward compatible. 2018-03-23 19:15:47 +03:00
Arik Fraimovich
e6f6c02f90 Fix: saving widget was sending too much data to the server, sometimes making saving fail. 2018-03-23 19:13:40 +03:00
Arik Fraimovich
565e66715f Fix: Fork button was shown in data only view, but wasn't working. 2018-03-23 19:13:15 +03:00
Arik Fraimovich
549de1355a Change: show friendly names in dynamic forms labels. 2018-03-23 19:10:26 +03:00
Arik Fraimovich
d892ed48cc Merge pull request #2387 from idalin/fix_ldap_login
fix no login form in ldap login page #2386
2018-03-22 14:04:30 +02:00
alon710
b96204654b adds mattermost dest 2018-03-22 00:29:49 +02:00
Arik Fraimovich
3c75c2bb60 Update CONTRIBUTING.md 2018-03-21 16:41:02 +02:00
Arik Fraimovich
db020576ed Merge pull request #2385 from kocsmy/fixex/cassandra-scylla-images
Add missing Cassandra and ScyllaDB images
2018-03-19 22:13:48 +02:00
idalin
5a93da3177 fix no login form in ldap login page #2386 2018-03-14 13:59:50 +08:00
Zsolt Kocsmarszky
d16285d239 Add missing images 2018-03-12 20:45:45 +01:00
Arik Fraimovich
0410d834d1 Merge pull request #2382 from deecay/webpack-ignore
Webpack: ignore vim swap files
2018-03-11 15:02:44 +02:00
deecay
b79abf52fd Webpack: ignore vim swap files 2018-03-10 17:48:11 +09:00
Levko Kravets
6a61057813 getredash/redash#2317 CR1
- cache GeoJSON to avoid multiple HTTP requests;
- allow to edit map bounds;
- optimize update map calls (do not re-render it every time);
- UI/X imporvements.
2018-03-08 14:46:09 +02:00
Arik Fraimovich
1a75d49041 Merge pull request #2379 from getredash/fix_empty_states
Change: close metadata database connection early in the execute query Celery task
2018-03-08 11:17:30 +02:00
Arik Fraimovich
c054731794 Change: close metadata database connection early in the execute query
Celery task. This to prevent the task holding an idle connection for
a long period of time, while waiting for the query to finish.
2018-03-08 11:06:15 +02:00
Arik Fraimovich
a824bd5da3 Merge pull request #2378 from getredash/fix_empty_states
Empty state screen fix: show connect data source link only to admins
2018-03-08 09:53:29 +02:00
Arik Fraimovich
e1ff31718e fix circle.yml 2018-03-08 09:46:45 +02:00
Arik Fraimovich
797b5582ac Fix: show connect data source link only to admins 2018-03-08 09:43:30 +02:00
Valentin Ouvrard
452904398f Fix docker-entrypoint broke for Other name than "postgres" 2018-03-08 10:04:29 +11:00
Levko Kravets
517f95fa01 Better resize handling 2018-03-07 09:14:24 +02:00
Levko Kravets
d5ee9cd007 Use data from current record for tooltip and popup contents 2018-03-06 21:40:27 +02:00
Levko Kravets
5918253022 Add option to align legend text
Remove Leaflet attribution
2018-03-06 21:24:39 +02:00
Levko Kravets
2f30dbf645 getredash/redash#2317 Choropleth visualization 2018-03-06 20:42:48 +02:00
Arik Fraimovich
88deb5fc47 Merge pull request #2372 from kravets-levko/fix/dancing-widgets
Fix: dashboard "dancing" widgets (when auto-height enabled)
2018-03-06 15:07:14 +02:00
Levko Kravets
27c7e86297 Fix: dashboard "dancing" widgets (when auto-height enabled) 2018-03-06 14:42:15 +02:00
Arik Fraimovich
051f12c712 Fix tags regex in circle.yml 2018-03-05 12:37:31 +02:00
Arik Fraimovich
f9ab83ed80 Merge pull request #2369 from getredash/query-refresh
Add: configuration for query refresh intervals.
2018-03-05 11:28:40 +02:00
Arik Fraimovich
c24bfe82e0 Update changelog. 2018-03-05 11:22:49 +02:00
Arik Fraimovich
4f27de3252 Add: configuration for query refresh intervals. 2018-03-05 11:22:08 +02:00
Arik Fraimovich
bf86d17d56 Stop spamming Docker Hub 2018-03-04 15:36:14 +02:00
Arik Fraimovich
54e23a7c84 Update CHANGELOG and update version. 2018-03-04 15:11:54 +02:00
Arik Fraimovich
6049e2df17 Merge pull request #2356 from kravets-levko/fix/dashboard-auto-height
Dashboard fixes and improvements
2018-03-04 14:50:48 +02:00
Arik Fraimovich
025af41616 Merge pull request #2260 from Top20Talent/master
Extend the Prometheus query runner to support the range query
2018-03-04 10:57:30 +02:00
Arik Fraimovich
d80f93a59d Merge pull request #2355 from jezdez/glue-table-paginator-upstream
Use a paginator when iterating over glue tables
2018-03-04 10:56:56 +02:00
Arik Fraimovich
5aa4b28d53 Merge pull request #2354 from jezdez/redash-extensions
Add a Redash extension system based on Python entrypoints.
2018-03-04 10:56:08 +02:00
Arik Fraimovich
6a072dd33a Merge pull request #2363 from kravets-levko/bug/query-page-unscrollable
Query page long visualization is unscrollable
2018-03-04 10:53:33 +02:00
Levko Kravets
67995085c6 getredash/redash#2361 2018-03-02 13:12:45 +02:00
Levko Kravets
9295a9d8fb Dashboard: save only changed widgets 2018-03-01 16:28:34 +02:00
Levko Kravets
43cd24927f gridsterAutoHeight: disable auto-height when widget resized by user 2018-03-01 14:46:53 +02:00
Jannis Leidel
6b41644557 Add a Redash extension system based on Python entrypoints.
This is along the lines of what Flask does for CLI plugins:

  http://flask.pocoo.org/docs/0.12/cli/#cli-plugins

The API allows specifying Python callbacks that receive the
Redash Flask app as the only argument and allow extending
the Redash process with the usual Flask API as needed. This
does not cover front-end specific extensions (yet).
2018-02-28 23:37:03 +01:00
Blake Imsland
dceb58cd79 Use a paginator when iterating over glue tables
Without this you only get the first 100 tables in the glue database
2018-02-28 22:10:54 +01:00
Arik Fraimovich
570187fc1f Merge pull request #2353 from kocsmy/fixes/filter-spacing-on-dashboards
Fix small spacing glitch on dashboard widgets
2018-02-28 18:16:09 +02:00
Zsolt Kocsmarszky
ca4663e3a4 fix small spacing glitch on dashboard widgets 2018-02-28 17:08:55 +01:00
Arik Fraimovich
6e097d5cec Merge pull request #2352 from kravets-levko/bug/query-result-column-names
Cast column names to string
2018-02-28 17:08:28 +02:00
Arik Fraimovich
2c95231fd7 Merge pull request #2334 from tonyjiangh/feat/funnel_visualization
Add funnel visualization
2018-02-28 17:06:37 +02:00
Hao Jiang
0d80156eec Rename visualization options for review 2018-02-28 23:44:42 +09:00
Levko Kravets
08c709c2ec getredash/redash#1960 Cast column names to string; code style fixes 2018-02-28 13:28:15 +02:00
Arik Fraimovich
bb28b2f0fb Merge pull request #2350 from getredash/auth
Authentication related settings improvements
2018-02-28 12:16:11 +02:00
Arik Fraimovich
0e4313de52 Merge pull request #2349 from kravets-levko/fix/dashboard-issues
Fix/dashboard issues
2018-02-27 23:22:59 +02:00
Arik Fraimovich
e76e29df24 Merge pull request #2348 from kocsmy/fixes/prometheus-logo
Add missing prometheus and snowflake logos
2018-02-27 23:21:09 +02:00
Arik Fraimovich
d1062ce0c4 Properly generate login URLs. 2018-02-27 23:14:57 +02:00
Arik Fraimovich
9ddf3745b8 Configuration for Google Apps. 2018-02-27 23:10:10 +02:00
Arik Fraimovich
0eefc7b592 join array filter. 2018-02-27 23:09:21 +02:00
Arik Fraimovich
bea035025e Callout sections css. 2018-02-27 23:09:12 +02:00
Arik Fraimovich
33aa7b72b7 Enable debug mode for Flask. 2018-02-27 23:09:00 +02:00
Arik Fraimovich
ca54d23f92 Remove unused LESS code. 2018-02-27 23:08:43 +02:00
Levko Kravets
7d6244a322 Revisit default options; set auto-height for Table viz by default 2018-02-27 18:25:57 +02:00
Levko Kravets
f61a74bbee Fix: 'Cannot read property of null' JS error when going away from dashboard page 2018-02-27 18:25:21 +02:00
Levko Kravets
7d258908c6 getredash/redash#2108 Fix: length limit increased 2018-02-27 18:24:26 +02:00
Zsolt Kocsmarszky
0e53356589 add missing prometheus and snowflake logos 2018-02-27 16:52:33 +01:00
Hao Jiang
aa43dcdb2b Add hover text for full step name 2018-02-27 23:22:41 +09:00
Arik Fraimovich
093c48505a Merge pull request #2347 from kravets-levko/fix/download-dataset-dropup
Fix: download dataset dropup is being cut off
2018-02-27 15:30:57 +02:00
Levko Kravets
c19ef632aa Fix: download dataset dropup is being cut off 2018-02-27 15:24:37 +02:00
Arik Fraimovich
bb1455ec71 Merge pull request #2346 from getredash/auth
Authentication fixes (SAML and login page)
2018-02-26 21:11:52 +02:00
Arik Fraimovich
09af43c4a7 remove unnecessary test. 2018-02-26 20:57:21 +02:00
Arik Fraimovich
35594ecb00 Always show login page and generate URLs with url_for.
Close #1259, #2235.
2018-02-26 20:46:27 +02:00
Arik Fraimovich
3044c77309 Make SAML work in MULTI_ORG mode. 2018-02-26 20:40:28 +02:00
Hao Jiang
0632044573 Handle corner cases 2018-02-26 22:21:05 +09:00
jubel
e6551e9774 remove the type 2018-02-26 14:58:30 +08:00
Arik Fraimovich
11d09b2f09 Fix empty-state logic for MULTI_ORG mode 2018-02-25 11:49:07 +02:00
Arik Fraimovich
8676b846c2 Add support for symlinks in webpack config 2018-02-25 11:13:54 +02:00
Arik Fraimovich
d4f98aa7f7 Merge pull request #2337 from ariarijp/upgrade-sqlparse
Upgrade sqlparse to 0.2.4
2018-02-24 21:29:08 +02:00
Arik Fraimovich
35458e846c Merge pull request #2338 from kravets-levko/fix/table-columns-nowrap
Custom CSS based on column type
2018-02-24 21:28:10 +02:00
jubel
49e9133fed style adjustment 2018-02-24 16:35:10 +08:00
Levko Kravets
5dd76674ff getredash/redash#2215 Custom CSS based on column type 2018-02-23 11:19:07 +02:00
Hao Jiang
8bdd5ff662 Fix sort order 2018-02-23 09:25:04 +09:00
Hao Jiang
9e68b36de6 Add support for selecting column to sort 2018-02-23 09:18:37 +09:00
Hao Jiang
e78bfb2e9a Add support for column mapping 2018-02-23 09:15:56 +09:00
ariarijp
f5d4ca85d8 Upgrade sqlparse to 0.2.4
Add test case for /api/queries/format
2018-02-23 01:24:34 +09:00
Arik Fraimovich
c65b637bc4 Merge pull request #2336 from kravets-levko/fix/settings-tabs-order
Explicitly set order of tabs on Settings page
2018-02-22 15:31:52 +02:00
Levko Kravets
062efe349f Explicitly set order of tabs on Settings page 2018-02-22 15:16:09 +02:00
Arik Fraimovich
696f46d64b Merge pull request #2252 from kocsmy/design/empty-states
Empty states for Home, Dashboards, Queries and Alerts
2018-02-22 11:26:13 +02:00
Arik Fraimovich
42b88d9a32 Ignore archived objects. 2018-02-22 11:04:13 +02:00
Arik Fraimovich
3a840fcc5d Implement empty states logic. 2018-02-22 11:02:12 +02:00
Hao Jiang
a333abcaa5 Use ColorPalette instead 2018-02-22 08:44:39 +09:00
Hao Jiang
d583f6f273 Add funnel visualization 2018-02-21 22:41:31 +09:00
Arik Fraimovich
a1aeb1d614 Merge pull request #2330 from getredash/arikfr-patch-1
Sort widgets by col/row to make sure they are placed correctly
2018-02-21 14:49:10 +02:00
Arik Fraimovich
bd13b78e21 Merge pull request #2320 from kravets-levko/bug/few-bugfixes
Few bugfixes
2018-02-20 22:41:05 +02:00
Arik Fraimovich
4d44be76ac Create <empty-state> component. 2018-02-20 22:29:07 +02:00
Levko Kravets
8cd758cfb6 Update 'absoluteUrl' function 2018-02-20 22:13:48 +02:00
Arik Fraimovich
9a1077d192 Merge branch 'master' into design/empty-states 2018-02-20 21:09:43 +02:00
Arik Fraimovich
bc4f174e22 Sort widgets by col/row to make sure they are placed correctly 2018-02-20 20:45:32 +02:00
Levko Kravets
046595825c Rename: fullUrl -> absoluteUrl 2018-02-19 23:19:03 +02:00
Arik Fraimovich
e0c5eabdd5 Merge pull request #2324 from kravets-levko/bug/type-of-first-table
User shouldn't be able to change the type of the first table
2018-02-19 23:07:26 +02:00
Arik Fraimovich
06e9d192ef Merge pull request #2325 from benmanns/patch-1
Reduce restart to unless-stopped for Redis and Postgres
2018-02-19 22:33:43 +02:00
Arik Fraimovich
906c8fc767 Merge pull request #2282 from kocsmy/design/improve-default-visualisation-colors
Improve visualisation colors
2018-02-19 22:32:37 +02:00
Benjamin Manns
6149e00c2f Reduce restart to unless-stopped for Redis and Postgres
`restart: always` will cause containers to restart when the machine is rebooted or the docker process restarts. Setting this to `unless-stopped` will mean the container will restart only if the docker process/computer was rebooted without stopping the container first. Otherwise, running `docker-compose stop` and rebooting will cause the docker containers all to start again (and goodbye laptop battery!)

We might also consider `restart: on-failures` which would mean that rebooting would _not_ cause containers to start again unless requested via `docker-compose up`.
2018-02-19 11:07:02 -05:00
Zsolt Kocsmarszky
b053770998 less vivid colors fo charts 2018-02-18 19:55:00 +01:00
Zsolt Kocsmarszky
45b380ca00 Merge branch 'master' into design/improve-default-visualisation-colors 2018-02-18 18:05:56 +01:00
Arik Fraimovich
631dad11d6 Merge pull request #2319 from kravets-levko/feature/server-pages-redesign
Redesign of all pages that are rendered by backend code
2018-02-18 14:53:49 +02:00
Levko Kravets
9647c37285 getredash/redash#2312 2018-02-18 14:08:44 +02:00
Zsolt Kocsmarszky
9e5f749556 Change default visualization colors 2018-02-17 18:21:11 +01:00
Zsolt Kocsmarszky
c69795c0cd Merge branch 'master' into design/improve-default-visualisation-colors 2018-02-17 17:47:02 +01:00
Levko Kravets
339f5537c6 Remove code related to hidden text widgets + UI improvements 2018-02-16 21:25:29 +02:00
Levko Kravets
d2c5be5bd5 Replace with window.location 2018-02-16 21:04:45 +02:00
Levko Kravets
21b753f9b5 Bug fixed: empty page when trying to open public dashboard 2018-02-16 20:59:53 +02:00
Levko Kravets
a28a6ea127 Bug: Invite user / Reset password links should be full (include shema and host) 2018-02-16 20:39:18 +02:00
Levko Kravets
38da3e9fef Add SSO login buttons to invite user page; UI fixes 2018-02-16 13:32:40 +02:00
Levko Kravets
f1aad2545e redesign of all pages that are rendered by backend code 2018-02-15 21:49:11 +02:00
Zsolt Kocsmarszky
2ed446be80 less neon green 2018-02-02 22:41:47 +01:00
Zsolt Kocsmarszky
caf0070d14 Merge branch 'master' into design/improve-default-visualisation-colors 2018-02-01 22:35:01 +01:00
Zsolt Kocsmarszky
c9b28785fb Improve visualisation colors 2018-02-01 22:32:30 +01:00
jubel
135af39fd4 support the range query of until now 2018-01-26 14:22:27 +08:00
jubel
8a2676701b extend the prometheus query runner to support the range query 2018-01-26 13:16:28 +08:00
Zsolt Kocsmarszky
e97fd7da5b add spacing between empty state window and content 2018-01-23 15:28:43 +01:00
Zsolt Kocsmarszky
3477a5540a add icons and fix smaller screen layout 2018-01-23 15:27:24 +01:00
Zsolt Kocsmarszky
490c8f38d8 Make section full width, remove double titles and minor styling improvements 2018-01-22 22:24:30 +01:00
Zsolt Kocsmarszky
5c5c08ae39 Add empty states for home, dashboards, queries and alerts 2018-01-22 22:03:06 +01:00
140 changed files with 4681 additions and 1131 deletions

View File

@@ -1,5 +1,90 @@
# Change Log # Change Log
## UNRELEASED
### Added
- MatterMost alert destination. @alon710
- Full screen view on map visualizations. @deecay
- Choropleth map visualization 🗺. @kravets-levko
- Report Celery queue size. @arikfr
- Load dashboard refresh rate from URL. @arikfr
- Configuration for query refresh intervals. @arikfr
### Changed
- TreasureData: improve query failure message. @toru-takahashi
- Update botocore version (fixes an issue with loading Athena tables). @arikfr
- Changed Map visualization name to "Map (Markers)" to distinguish from the Choropleth one. @arikfr
- Use MongoClient for ReplicaSet connections. @fmy
- Update pymongo version to support newer MongoDB versions. @arikfr
- Changed "his" to "their" in user creation form success message. @tnetennba3
- Show friendly names in dynamic forms labels. @arikfr
- Render safe HTML by default in tables to remain backward compatible. @arikfr
- Apply time limit to alert status checking task. @arikfr
- Plotly: increase Y value accuracy. @arikfr
- close metadata database connection early in the execute query Celery task. @arikfr
### Fixed
- Query page layout gets messed up when clicking on "cancel" in "Do you want to leave this page?" dialog. @kravets-levko
- docker-entrypoint broke for other database names than "postgres". @valentin2105
- (BigQuery) UDF URI was used even if empty. @arikfr
- Show correct Box Plot chart hover data. @deeccay
- Fork button shows in data only view, but not working. @arikfr
- Saving widget sends too much data to the server, sometimes making dashboard save fail. @arikfr
- DynamoDB: always return counter as a number rather than string. @arikfr
- MSSQL: UUID fields were detected as booleans. @arikfr
- The whole dashboard page reloads when clicking on refresh. @arikfr
- Line chart with category x-axis: when some values missing, wrong hints displayed on hover. @kravets-levko
- Second Y-axis not displayed when stacking enabled. @kravets-levko
- Widget with empty contents had extra 40px of white space (paddings of container). @kravets-levko
- Add scrollbars to pivot table widgets. @kravets-levko
- Multiple performance, usability and auto-height related fixes to the dashboard rendering engine (also switched to GridStack). @kravets-levko
- Login form missing on LDAP logging page. @idalin
- Empty state: show connect data source link only to admins. @arikfr
- Dashboard "dancing" widgets (when auto-height enabled). @kravets-levko
### Other
- Webpack: ignore vim swap files. @deecay
## v4.0.0-rc.1 - 2018-03-05
### Added
- Configuration for query refresh intervals.
- [Prometheus] Support for range queries. @jubel-han
- Extensions system based on Python entrypoints. @jezdez
- Funnel visualization. @tonyjiangh
- UI to edit allowed Google OAuth domains. @arikfr
- Empty state for homepage, alerts, queries and dashboards pages. @kocsmy, @arikfr
### Changed
- Maintain widget's auto-height state until it's been resized by the user. @kravets-levko
- Change default table viz width from 4 to 3 columns. @kravets-levko
- When saving dashboard adding or removing widgets, save only modified widgets (with changed size and/or position). @kravets-levko
- Don't allow disabling Password based login if no SSO is enabled. @arikfr
- Always show login page, even if password based login disabled. @arikfr
- Upgrade `sqlparse` to 0.2.4. @ariarijp
- Make sure datetime/number columns in table visualization don't wrap. @kravets-levko
- Explicitly set order of tabs in settings page. @kravets-levko
- User can no longer change the type of a saved visualization. @kravets-levko
- Update docker-compose.yml to restart postgres/redis containers `unless-stopped`. @benmanns
- New default colors for chart visualizations. @kocsmy
- Updated design of all the authentication pages (login, forgot password, etc). @kravets-levko
### Fixed
- Glue schemas with more than 100 tables were showing only first 100 tables. @jezdez
- Long visualizations dind't render scrollbars on some browsers. @kravets-levko
- When the dataset was returning some columns name as non strings, table couldn't be rendered. @kravets-levko
- Missing logos for Prometheus and Snowflake. @kocsmy
- Render correct link to LDAP login on login page. @arikfr
- Sort widgets by column/row to make sure they are placed correctly. @arikfr
- Public dashboards were not rendered due to Javascript error. @kravets-levko
## v4.0.0-beta - 2018-02-14 ## v4.0.0-beta - 2018-02-14
### Added ### Added

View File

@@ -60,7 +60,7 @@ If you would like to suggest an enhancement or ask for a new feature:
### Documentation ### Documentation
The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/user-guide) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface. The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/website/_kb) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
## Additional Notes ## Additional Notes

View File

@@ -23,10 +23,6 @@ server() {
} }
create_db() { create_db() {
while ! bash -c "echo > /dev/tcp/postgres/5432" &> /dev/null ; do
echo "Waiting for PostgreSQL container to become available."
sleep 5
done
exec /app/manage.py database create_tables exec /app/manage.py database create_tables
} }
@@ -72,6 +68,7 @@ case "$1" in
scheduler scheduler
;; ;;
dev_server) dev_server)
export FLASK_DEBUG=1
exec /app/manage.py runserver --debugger --reload -h 0.0.0.0 exec /app/manage.py runserver --debugger --reload -h 0.0.0.0
;; ;;
shell) shell)
@@ -96,3 +93,4 @@ case "$1" in
exec "$@" exec "$@"
;; ;;
esac esac

View File

@@ -18,8 +18,12 @@ test:
override: override:
- pytest --junitxml=$CIRCLE_TEST_REPORTS/junit.xml tests/ - pytest --junitxml=$CIRCLE_TEST_REPORTS/junit.xml tests/
deployment: deployment:
github_and_docker: tarball:
branch: [master, /release.*/] branch: [master, /release.*/]
commands:
- bin/pack
docker:
tag: /v[0-9]+(\.[0-9\-a-z]+)*/
commands: commands:
- bin/pack - bin/pack
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48"><defs><path id="a" d="M44.5 20H24v8.5h11.8C34.7 33.9 30.1 37 24 37c-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4C34.6 4.1 29.6 2 24 2 11.8 2 2 11.8 2 24s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="b"><use xlink:href="#a" overflow="visible"/></clipPath><path clip-path="url(#b)" fill="#FBBC05" d="M0 37V11l17 13z"/><path clip-path="url(#b)" fill="#EA4335" d="M0 11l17 13 7-6.1L48 14V0H0z"/><path clip-path="url(#b)" fill="#34A853" d="M0 37l30-23 7.9 1L48 0v48H0z"/><path clip-path="url(#b)" fill="#4285F4" d="M48 48L17 24l-4-3 35-10z"/></svg>

After

Width:  |  Height:  |  Size: 688 B

View File

@@ -72,3 +72,17 @@ strong {
position: relative; position: relative;
} }
.resize-vertical {
resize: vertical !important;
transition: height 0s !important;
}
.resize-horizontal {
resize: horizontal !important;
transition: width 0s !important;
}
.resize-both,
.resize-vertical.resize-horizontal {
resize: both !important;
transition: height 0s, width 0s !important;
}

View File

@@ -1,75 +0,0 @@
.contacts {
&:not(.c-profile) {
padding: 0 3px;
}
& > [class*="col-"] {
padding: 0 10px;
}
.c-item {
border: 1px solid #e2e2e2;
border-radius: 2px;
margin-bottom: 24px;
.ci-avatar {
display: block;
img {
width: 100%;
border-radius: 2px 2px 0 0;
}
}
}
.ci-avatar {
margin: -1px -1px 0;
}
.c-info {
text-align: center;
margin-top: 15px;
padding: 0 5px;
strong {
color: #000;
font-size: 14px;
font-weight: 500;
}
small {
color: #999;
margin-top: 3px;
}
strong,
small {
.text-overflow();
display: block;
}
}
.c-footer {
border-top: 1px solid #e2e2e2;
margin-top: 18px;
& > button {
padding: 4px 10px 3px;
color: #333;
display: block;
width: 100%;
text-align: center;
color: #333;
font-weight: 500;
border-radius: 2px;
background: #fff;
border: 0;
& > i {
font-size: 16px;
vertical-align: middle;
margin-top: -3px;
}
}
}
}

View File

@@ -1,29 +0,0 @@
.gridster .preview-holder {
border: none !important;
border-radius: 0 !important;
background: rgba(0, 0, 0, 0.5) !important;
}
.gridster li .heading {
border: #ddd;
background-color: #f5f5f5;
padding: 5px;
}
li.widget {
/*background-color:grey;*/
border-width: 1px;
border-style: solid;
border-color: grey;
opacity: 0.7;
cursor: move;
&:hover {
opacity: 1.0 !important;
-webkit-transition: opacity .6s;
-moz-transition: opacity .6s;
-o-transition: opacity .6s;
-ms-transition: opacity .6s;
transition: opacity .6s;
}
}

View File

@@ -62,7 +62,7 @@ div.table-name {
&:hover { &:hover {
background: fade(@redash-gray, 10%); background: fade(@redash-gray, 10%);
.copy-to-editor { .copy-to-editor {
display: flex; display: flex;
} }
@@ -75,7 +75,6 @@ div.table-name {
padding: 0; padding: 0;
.form-control { .form-control {
height: 30px;
margin-right: 5px; margin-right: 5px;
} }
} }

View File

@@ -1,48 +0,0 @@
.todo-lists {
padding: 15px 0 0 0;
position: relative;
.list-group-item {
margin: 0;
min-height: 36px;
&:not(:last-child) {
border-bottom: 1px solid #E2EBFF;
}
}
&:before {
content: "";
position: absolute;
top: 0;
background: #E2EBFF;
left: 50px;
width: 1px;
height: 100%;
z-index: 1;
}
.input-helper:before {
background: lighten(@red, 10%) !important;
}
}
.tl-item {
padding-left: 47px !important;
input:checked + .input-helper + span {
text-decoration: line-through;
color: #b5b5b5;
}
}
.todo-footer {
padding: 10px;
margin-top: 20px;
border-top: 1px solid #E2EBFF;
text-align: center;
position: relative;
z-index: 2;
background: #fff;
}

View File

@@ -1,3 +1,37 @@
.map-visualization-container { .map-visualization-container {
height: 500px; height: 500px;
> div:first-child {
width: 100%;
height: 100%;
z-index: 0;
}
.map-custom-control.leaflet-bar {
background: #fff;
padding: 10px;
margin: 10px;
position: absolute;
z-index: 1;
&.top-left {
left: 0;
top: 0;
}
&.top-right {
right: 0;
top: 0;
}
&.bottom-left {
left: 0;
bottom: 0;
}
&.bottom-right {
right: 0;
bottom: 0;
}
}
} }

View File

@@ -9,7 +9,6 @@
@import '~ui-select/dist/select.css'; @import '~ui-select/dist/select.css';
@import '~angular-toastr/src/toastr'; @import '~angular-toastr/src/toastr';
@import '~angular-resizable/src/angular-resizable.css'; @import '~angular-resizable/src/angular-resizable.css';
@import '~angular-gridster/src/angular-gridster';
@import '~pace-progress/themes/blue/pace-theme-minimal.css'; @import '~pace-progress/themes/blue/pace-theme-minimal.css';
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css'; @import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
@@ -51,7 +50,6 @@
@import 'inc/navbar'; @import 'inc/navbar';
@import 'inc/edit-in-place'; @import 'inc/edit-in-place';
@import 'inc/growl'; @import 'inc/growl';
@import 'inc/gridster';
@import 'inc/flex'; @import 'inc/flex';
@import 'inc/ace-editor'; @import 'inc/ace-editor';
@import 'inc/overlay'; @import 'inc/overlay';

View File

@@ -230,6 +230,7 @@ edit-in-place p.editable:hover {
.widget-wrapper { .widget-wrapper {
.body-container { .body-container {
filters { filters {
display: block;
padding-left: 15px; padding-left: 15px;
} }
} }
@@ -301,6 +302,15 @@ edit-in-place p.editable:hover {
flex-direction: column; flex-direction: column;
padding-bottom: 0; padding-bottom: 0;
padding-top: 0 !important; padding-top: 0 !important;
position: relative;
schema-browser {
position: absolute;
left: 15px;
top: 0;
right: 15px;
bottom: 0;
}
} }
} }
main { main {

View File

@@ -35,6 +35,20 @@ body {
clear: both; clear: both;
} }
.callout {
padding: 20px;
border: 1px solid #eee;
border-left-width: 5px;
border-radius: 3px;
}
.callout-warning {
border-left-color: #aa6708;
}
.callout-info {
border-left-color: #1b809e;
}
// Fixed width layout for specific pages // Fixed width layout for specific pages
@media (min-width: 768px) { @media (min-width: 768px) {

View File

@@ -0,0 +1,75 @@
/** LESS Plugins **/
@import 'inc/less-plugins/for';
/** Load Main Bootstrap LESS files **/
@import '~bootstrap/less/bootstrap';
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
@import 'inc/variables';
@import 'inc/mixins';
@import 'inc/font';
@import 'inc/print';
@import 'inc/bootstrap-overrides';
@import 'inc/base';
@import 'inc/generics';
@import 'inc/form';
@import 'inc/button';
@import 'inc/404';
@import 'inc/ie-warning';
@import 'inc/flex';
@import 'redash/redash-newstyle';
html, body {
height: 100%;
margin: 0;
padding: 0;
background: #F6F8F9;
}
hr {
border-top-width: 2px;
margin: 25px 0;
}
.tiled {
padding: 25px;
}
.header {
margin-top: -100px;
img {
height: 40px;
}
}
.fixed-width-page {
width: 500px;
}
.login-button {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
&:first-of-type {
margin-top: 0;
}
&:last-of-type {
margin-bottom: 0;
}
img {
height: 25px;
margin-right: 5px;
}
&:before {
content: "";
display: inline-block;
height: 25px;
}
}

View File

@@ -27,9 +27,9 @@ const layouts = {
function selectLayout(route) { function selectLayout(route) {
let layout = layouts.default; let layout = layouts.default;
if (route.layout) { if (route.layout) {
layout = layouts[route.layout]; layout = layouts[route.layout] || layouts.default;
} else if (!route.authenticated) { } else if (!route.authenticated) {
layout = layout.defaultSignedOut; layout = layouts.defaultSignedOut;
} }
return layout; return layout;
} }
@@ -52,9 +52,7 @@ class AppViewComponent {
// For routes that need authentication, check if session is already // For routes that need authentication, check if session is already
// loaded, and load it if not. // loaded, and load it if not.
logger('Requested authenticated route: ', route); logger('Requested authenticated route: ', route);
if (Auth.isAuthenticated()) { if (!Auth.isAuthenticated()) {
this.applyLayout($$route);
} else {
event.preventDefault(); event.preventDefault();
// Auth.requireSession resolves only if session loaded // Auth.requireSession resolves only if session loaded
Auth.requireSession().then(() => { Auth.requireSession().then(() => {
@@ -62,12 +60,17 @@ class AppViewComponent {
$route.reload(); $route.reload();
}); });
} }
} else {
this.applyLayout(route.$$route);
} }
}); });
$rootScope.$on('$routeChangeSuccess', (event, route) => {
const $$route = route.$$route || { authenticated: true };
this.applyLayout($$route);
});
$rootScope.$on('$routeChangeError', (event, current, previous, rejection) => { $rootScope.$on('$routeChangeError', (event, current, previous, rejection) => {
const $$route = current.$$route || { authenticated: true };
this.applyLayout($$route);
throw new PromiseRejectionError(rejection); throw new PromiseRejectionError(rejection);
}); });
} }

View File

@@ -9,10 +9,11 @@
</p> </p>
<div ng-show="$ctrl.isTextBox"> <div ng-show="$ctrl.isTextBox">
<div class="form-group"> <div class="form-group m-b-0">
<textarea class="form-control" ng-model="$ctrl.text" ng-model-options="{ debounce: 200 }" rows="3" autofocus></textarea> <textarea class="form-control resize-vertical" style="min-height: 100px"
ng-model="$ctrl.text" ng-model-options="{ debounce: 200 }" rows="5" autofocus></textarea>
</div> </div>
<div ng-show="$ctrl.text"> <div ng-show="$ctrl.text" class="m-t-15">
<strong>Preview:</strong> <strong>Preview:</strong>
<p ng-bind-html="$ctrl.text | markdown"></p> <p ng-bind-html="$ctrl.text | markdown"></p>
</div> </div>
@@ -61,10 +62,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-group" ng-if="$ctrl.isTextBox">
<label><input type="checkbox" ng-model="$ctrl.isHidden">&nbsp;Hidden</label>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -16,7 +16,6 @@ const AddWidgetDialog = {
// Textbox // Textbox
this.text = ''; this.text = '';
this.isHidden = false;
// Visualization // Visualization
this.selectedQuery = null; this.selectedQuery = null;
@@ -84,7 +83,7 @@ const AddWidgetDialog = {
visualization_id: selectedVis && selectedVis.id, visualization_id: selectedVis && selectedVis.id,
dashboard_id: this.dashboard.id, dashboard_id: this.dashboard.id,
options: { options: {
isHidden: this.isTextBox && this.isHidden, isHidden: false,
position: {}, position: {},
}, },
visualization: selectedVis, visualization: selectedVis,

View File

@@ -4,7 +4,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<textarea class="form-control" ng-model="$ctrl.widget.new_text" rows="3"></textarea> <textarea class="form-control" style="resize: vertical" ng-model="$ctrl.widget.new_text" rows="3"></textarea>
</div> </div>
<div ng-show="$ctrl.widget.new_text"> <div ng-show="$ctrl.widget.new_text">
<strong>Preview:</strong> <strong>Preview:</strong>

View File

@@ -0,0 +1,87 @@
import $ from 'jquery';
import _ from 'underscore';
import 'jquery-ui/ui/widgets/draggable';
import 'jquery-ui/ui/widgets/droppable';
import 'jquery-ui/ui/widgets/resizable';
import 'gridstack/dist/gridstack.css';
// eslint-disable-next-line import/first
import gridstack from 'gridstack';
function sequence(...fns) {
fns = _.filter(fns, _.isFunction);
if (fns.length > 0) {
return function sequenceWrapper(...args) {
for (let i = 0; i < fns.length; i += 1) {
fns[i].apply(this, args);
}
};
}
return _.noop;
}
// eslint-disable-next-line import/prefer-default-export
function JQueryUIGridStackDragDropPlugin(grid) {
gridstack.GridStackDragDropPlugin.call(this, grid);
}
gridstack.GridStackDragDropPlugin.registerPlugin(JQueryUIGridStackDragDropPlugin);
JQueryUIGridStackDragDropPlugin.prototype = Object.create(gridstack.GridStackDragDropPlugin.prototype);
JQueryUIGridStackDragDropPlugin.prototype.constructor = JQueryUIGridStackDragDropPlugin;
JQueryUIGridStackDragDropPlugin.prototype.resizable = function resizable(el, opts, key, value) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.resizable(opts);
} else if (opts === 'option') {
el.resizable(opts, key, value);
} else {
el.resizable(_.extend({}, this.grid.opts.resizable, {
// run user-defined callback before internal one
start: sequence(this.grid.opts.resizable.start, opts.start),
// this and next - run user-defined callback after internal one
stop: sequence(opts.stop, this.grid.opts.resizable.stop),
resize: sequence(opts.resize, this.grid.opts.resizable.resize),
}));
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.draggable = function draggable(el, opts) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.draggable(opts);
} else {
el.draggable(_.extend({}, this.grid.opts.draggable, {
containment: this.grid.opts.isNested ? this.grid.container.parent() : null,
// run user-defined callback before internal one
start: sequence(this.grid.opts.draggable.start, opts.start),
// this and next - run user-defined callback after internal one
stop: sequence(opts.stop, this.grid.opts.draggable.stop),
drag: sequence(opts.drag, this.grid.opts.draggable.drag),
}));
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.droppable = function droppable(el, opts) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.droppable(opts);
} else {
el.droppable({
accept: opts.accept,
});
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.isDroppable = function isDroppable(el) {
return Boolean($(el).data('droppable'));
};
JQueryUIGridStackDragDropPlugin.prototype.on = function on(el, eventName, callback) {
$(el).on(eventName, callback);
return this;
};

View File

@@ -0,0 +1,55 @@
.grid-stack {
// Same options as in JS
@gridstack-margin: 15px;
@gridstack-width: 6;
margin-right: -@gridstack-margin;
.gridstack-columns(@column, @total) when (@column > 0) {
@value: 100% * (@column / @total);
> .grid-stack-item[data-gs-min-width="@{column}"] { min-width: @value }
> .grid-stack-item[data-gs-max-width="@{column}"] { max-width: @value }
> .grid-stack-item[data-gs-width="@{column}"] { width: @value }
> .grid-stack-item[data-gs-x="@{column}"] { left: @value }
.gridstack-columns((@column - 1), @total); // next iteration
}
.gridstack-columns(@gridstack-width, @gridstack-width);
.grid-stack-item {
.grid-stack-item-content {
overflow: visible !important;
box-shadow: none !important;
opacity: 1 !important;
left: 0 !important;
right: @gridstack-margin !important;
}
.ui-resizable-handle {
background: none !important;
&.ui-resizable-w,
&.ui-resizable-sw {
left: 0 !important;
}
&.ui-resizable-e,
&.ui-resizable-se {
right: @gridstack-margin !important;
}
}
&.grid-stack-placeholder > .placeholder-content {
border: 0;
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
left: 0 !important;
right: @gridstack-margin !important;
}
}
&.grid-stack-one-column-mode > .grid-stack-item {
margin-bottom: @gridstack-margin !important;
}
}

View File

@@ -0,0 +1,444 @@
import $ from 'jquery';
import _ from 'underscore';
import './gridstack';
import './gridstack.less';
function toggleAutoHeightClass($element, isEnabled) {
const className = 'widget-auto-height-enabled';
if (isEnabled) {
$element.addClass(className);
} else {
$element.removeClass(className);
}
}
function computeAutoHeight($element, grid, node, minHeight, maxHeight) {
const wrapper = $element[0];
const element = wrapper.querySelector('.scrollbox, .spinner-container');
let resultHeight = _.isObject(node) ? node.height : 1;
if (element) {
const childrenBounds = _.chain(element.children)
.map((child) => {
const bounds = child.getBoundingClientRect();
const style = window.getComputedStyle(child);
return {
top: bounds.top - parseFloat(style.marginTop),
bottom: bounds.bottom + parseFloat(style.marginBottom),
};
})
.reduce((result, bounds) => ({
top: Math.min(result.top, bounds.top),
bottom: Math.max(result.bottom, bounds.bottom),
}))
.value() || { top: 0, bottom: 0 };
// Height of controls outside visualization area
const bodyWrapper = wrapper.querySelector('.body-container');
if (bodyWrapper) {
const elementStyle = window.getComputedStyle(element);
const controlsHeight = _.chain(bodyWrapper.children)
.filter(n => n !== element)
.reduce((result, n) => {
const b = n.getBoundingClientRect();
return result + (b.bottom - b.top);
}, 0)
.value();
const additionalHeight = grid.opts.verticalMargin +
// include container paddings too
parseFloat(elementStyle.paddingTop) + parseFloat(elementStyle.paddingBottom) +
// add few pixels for scrollbar (if visible)
(element.scrollWidth > element.offsetWidth ? 16 : 0);
const contentsHeight = childrenBounds.bottom - childrenBounds.top;
const cellHeight = grid.cellHeight() + grid.opts.verticalMargin;
resultHeight = Math.ceil(Math.round(controlsHeight + contentsHeight + additionalHeight) / cellHeight);
}
}
// minHeight <= resultHeight <= maxHeight
return Math.min(Math.max(minHeight, resultHeight), maxHeight);
}
function gridstack($parse, dashboardGridOptions) {
return {
restrict: 'A',
replace: false,
scope: {
editing: '=',
batchUpdate: '=', // set by directive - for using in wrapper components
isOneColumnMode: '=',
},
controller() {
this.$el = null;
this.resizingWidget = null;
this.draggingWidget = null;
this.grid = () => (this.$el ? this.$el.data('gridstack') : null);
this.addWidget = ($element, item, itemId) => {
const grid = this.grid();
if (grid) {
grid.addWidget(
$element,
item.col, item.row, item.sizeX, item.sizeY,
false, // auto position
item.minSizeX, item.maxSizeX, item.minSizeY, item.maxSizeY,
itemId,
);
grid._updateStyles(grid.grid.getGridHeight());
}
};
this.updateWidget = ($element, item) => {
this.update((grid) => {
grid.update($element, item.col, item.row, item.sizeX, item.sizeY);
grid.minWidth($element, item.minSizeX);
grid.maxWidth($element, item.maxSizeX);
grid.minHeight($element, item.minSizeY);
grid.maxHeight($element, item.maxSizeY);
});
};
this.batchUpdateWidgets = (items) => {
// This method is used to update multiple widgets with a single
// reflow (for example, restore positions when dashboard editing cancelled).
// "dirty" part of code: updating grid and DOM nodes directly.
// layout reflow is triggered by `batchUpdate`/`commit` calls
this.update((grid) => {
_.each(grid.grid.nodes, (node) => {
const item = items[node.id];
if (item) {
if (_.isNumber(item.col)) {
node.x = parseFloat(item.col);
node.el.attr('data-gs-x', node.x);
node._dirty = true;
}
if (_.isNumber(item.row)) {
node.y = parseFloat(item.row);
node.el.attr('data-gs-y', node.y);
node._dirty = true;
}
if (_.isNumber(item.sizeX)) {
node.width = parseFloat(item.sizeX);
node.el.attr('data-gs-width', node.width);
node._dirty = true;
}
if (_.isNumber(item.sizeY)) {
node.height = parseFloat(item.sizeY);
node.el.attr('data-gs-height', node.height);
node._dirty = true;
}
if (_.isNumber(item.minSizeX)) {
node.minWidth = parseFloat(item.minSizeX);
node.el.attr('data-gs-min-width', node.minWidth);
node._dirty = true;
}
if (_.isNumber(item.maxSizeX)) {
node.maxWidth = parseFloat(item.maxSizeX);
node.el.attr('data-gs-max-width', node.maxWidth);
node._dirty = true;
}
if (_.isNumber(item.minSizeY)) {
node.minHeight = parseFloat(item.minSizeY);
node.el.attr('data-gs-min-height', node.minHeight);
node._dirty = true;
}
if (_.isNumber(item.maxSizeY)) {
node.maxHeight = parseFloat(item.maxSizeY);
node.el.attr('data-gs-max-height', node.maxHeight);
node._dirty = true;
}
}
});
});
};
this.removeWidget = ($element) => {
const grid = this.grid();
if (grid) {
grid.removeWidget($element, false);
}
};
this.getNodeByElement = (element) => {
const grid = this.grid();
if (grid && grid.grid) {
// This method seems to be internal
return grid.grid.getNodeDataByDOMEl($(element));
}
};
this.setWidgetId = ($element, id) => {
// `gridstack` has no API method to change node id; but since it's not used
// by library, we can just update grid and DOM node
const node = this.getNodeByElement($element);
if (node) {
node.id = id;
$element.attr('data-gs-id', _.isUndefined(id) ? null : id);
}
};
this.setEditing = (value) => {
const grid = this.grid();
if (grid) {
if (value) {
grid.enable();
} else {
grid.disable();
}
}
};
this.update = (callback) => {
const grid = this.grid();
if (grid) {
grid.batchUpdate();
try {
if (_.isFunction(callback)) {
callback(grid);
}
// `_updateStyles` is internal, but grid sometimes "forgets"
// to rebuild stylesheet, so we need to force it
grid._updateStyles(grid.grid.getGridHeight());
} finally {
grid.commit();
}
}
};
},
link: ($scope, $element, $attr, controller) => {
const batchUpdateAssignable = _.isFunction($parse($attr.batchUpdate).assign);
const isOneColumnModeAssignable = _.isFunction($parse($attr.batchUpdate).assign);
let enablePolling = true;
$element.addClass('grid-stack');
$element.gridstack({
auto: false,
verticalMargin: dashboardGridOptions.margins,
// real row height will be `cellHeight` + `verticalMargin`
cellHeight: dashboardGridOptions.rowHeight - dashboardGridOptions.margins,
width: dashboardGridOptions.columns, // columns
height: 0, // max rows (0 for unlimited)
animate: true,
float: false,
minWidth: dashboardGridOptions.mobileBreakPoint,
resizable: {
handles: 'e, se, s, sw, w',
start: (event, ui) => {
controller.resizingWidget = ui.element;
$(ui.element).trigger(
'gridstack.resize-start',
controller.getNodeByElement(ui.element),
);
},
stop: (event, ui) => {
controller.resizingWidget = null;
$(ui.element).trigger(
'gridstack.resize-end',
controller.getNodeByElement(ui.element),
);
controller.update();
},
},
draggable: {
start: (event, ui) => {
controller.draggingWidget = ui.helper;
$(ui.helper).trigger(
'gridstack.drag-start',
controller.getNodeByElement(ui.helper),
);
},
stop: (event, ui) => {
controller.draggingWidget = null;
$(ui.helper).trigger(
'gridstack.drag-end',
controller.getNodeByElement(ui.helper),
);
controller.update();
},
},
});
controller.$el = $element;
// `change` events sometimes fire too frequently (for example,
// on initial rendering when all widgets add themselves to grid, grid
// will fire `change` event will _all_ items available at that moment).
// Collect changed items, and then delegate event with some delay
let changedNodes = {};
const triggerChange = _.debounce(() => {
_.each(changedNodes, (node) => {
if (node.el) {
$(node.el).trigger('gridstack.changed', node);
}
});
changedNodes = {};
});
$element.on('change', (event, nodes) => {
nodes = _.isArray(nodes) ? nodes : [];
_.each(nodes, (node) => {
changedNodes[node.id] = node;
});
triggerChange();
});
$scope.$watch('editing', (value) => {
controller.setEditing(!!value);
});
if (batchUpdateAssignable) {
$scope.batchUpdate = controller.batchUpdateWidgets;
}
$scope.$on('$destroy', () => {
enablePolling = false;
controller.$el = null;
});
// `gridstack` does not provide API to detect when one-column mode changes.
// Just watch `$element` for specific class
function updateOneColumnMode() {
const grid = controller.grid();
if (grid) {
const isOneColumnMode = $element.hasClass(grid.opts.oneColumnModeClass);
if ($scope.isOneColumnMode !== isOneColumnMode) {
$scope.isOneColumnMode = isOneColumnMode;
$scope.$applyAsync();
}
}
if (enablePolling) {
setTimeout(updateOneColumnMode, 150);
}
}
// Start polling only if we can update scope binding; otherwise it
// will just waisting CPU time (example: public dashboards don't need it)
if (isOneColumnModeAssignable) {
updateOneColumnMode();
}
},
};
}
function gridstackItem($timeout) {
return {
restrict: 'A',
replace: false,
require: '^gridstack',
scope: {
gridstackItem: '=',
gridstackItemId: '@',
},
link: ($scope, $element, $attr, controller) => {
let enablePolling = true;
let heightBeforeResize = null;
controller.addWidget($element, $scope.gridstackItem, $scope.gridstackItemId);
// these events are triggered only on user interaction
$element.on('gridstack.resize-start', () => {
const node = controller.getNodeByElement($element);
heightBeforeResize = _.isObject(node) ? node.height : null;
});
$element.on('gridstack.resize-end', (event, node) => {
const item = $scope.gridstackItem;
if (
_.isObject(node) && _.isObject(item) &&
(node.height !== heightBeforeResize) &&
(heightBeforeResize !== null)
) {
item.autoHeight = false;
toggleAutoHeightClass($element, item.autoHeight);
$scope.$applyAsync();
}
});
$element.on('gridstack.changed', (event, node) => {
const item = $scope.gridstackItem;
if (_.isObject(node) && _.isObject(item)) {
let dirty = false;
if (node.x !== item.col) {
item.col = node.x;
dirty = true;
}
if (node.y !== item.row) {
item.row = node.y;
dirty = true;
}
if (node.width !== item.sizeX) {
item.sizeX = node.width;
dirty = true;
}
if (node.height !== item.sizeY) {
item.sizeY = node.height;
dirty = true;
}
if (dirty) {
$scope.$applyAsync();
}
}
});
$scope.$watch('gridstackItem.autoHeight', () => {
const item = $scope.gridstackItem;
if (_.isObject(item)) {
toggleAutoHeightClass($element, item.autoHeight);
} else {
toggleAutoHeightClass($element, false);
}
});
$scope.$watch('gridstackItemId', () => {
controller.setWidgetId($element, $scope.gridstackItemId);
});
$scope.$on('$destroy', () => {
enablePolling = false;
$timeout(() => {
controller.removeWidget($element);
});
});
function update() {
if (!controller.resizingWidget && !controller.draggingWidget) {
const item = $scope.gridstackItem;
const grid = controller.grid();
if (grid && _.isObject(item) && item.autoHeight) {
const sizeY = computeAutoHeight(
$element, grid, controller.getNodeByElement($element),
item.minSizeY, item.maxSizeY,
);
if (sizeY !== item.sizeY) {
item.sizeY = sizeY;
controller.updateWidget($element, { sizeY });
$scope.$applyAsync();
}
}
}
if (enablePolling) {
setTimeout(update, 150);
}
}
update();
},
};
}
export default function init(ngModule) {
ngModule.directive('gridstack', gridstack);
ngModule.directive('gridstackItem', gridstackItem);
}

View File

@@ -1,6 +1,6 @@
<div class="widget-wrapper"> <div class="widget-wrapper">
<div class="tile body-container" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type" <div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
ng-switch="$ctrl.queryResult.getStatus()"> ng-switch="$ctrl.widget.getQueryResult().getStatus()">
<div class="body-row"> <div class="body-row">
<div class="t-header widget clearfix"> <div class="t-header widget clearfix">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"> <div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
@@ -14,23 +14,23 @@
</div> </div>
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000"> <ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'csv')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'csv')}}" target="_self">Download as CSV File</a></li> <li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'csv')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'csv')}}" target="_self">Download as CSV File</a></li>
<li ng-class="{'disabled': $ctrl.queryResult.isEmpty()}"><a ng-href="{{$ctrl.queryResult.getLink($ctrl.query.id, 'xlsx')}}" download="{{$ctrl.queryResult.getName($ctrl.query.name, 'xlsx')}}" target="_self">Download as Excel File</a></li> <li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'xlsx')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
<li><a ng-href="{{$ctrl.query.getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</a></li> <li><a ng-href="{{$ctrl.widget.getQuery().getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li> <li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
</ul> </ul>
</div> </div>
<div class="th-title"> <div class="th-title">
<p class="hidden-print"> <p class="hidden-print">
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.query.name}}</span> <span ng-hide="$ctrl.canViewQuery">{{$ctrl.widget.getQuery().name}}</span>
<query-link query="$ctrl.query" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link> <query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
<small><visualization-name visualization="$ctrl.widget.visualization"/></small> <small><visualization-name visualization="$ctrl.widget.visualization"/></small>
</p> </p>
<p class="visible-print"> <p class="visible-print">
{{$ctrl.query.name}} {{$ctrl.widget.getQuery().name}}
<visualization-name visualization="$ctrl.widget.visualization"/> <visualization-name visualization="$ctrl.widget.visualization"/>
</p> </p>
<div class="text-muted query--description" ng-bind-html="$ctrl.query.description | markdown"></div> <div class="text-muted query--description" ng-bind-html="$ctrl.widget.getQuery().description | markdown"></div>
</div> </div>
</div> </div>
<div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0"> <div class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
@@ -39,10 +39,10 @@
</div> </div>
<div ng-switch-when="failed" class="body-row-auto scrollbox"> <div ng-switch-when="failed" class="body-row-auto scrollbox">
<div class="alert alert-danger m-5" ng-show="$ctrl.queryResult.getError()">Error running query: <strong>{{$ctrl.queryResult.getError()}}</strong></div> <div class="alert alert-danger m-5" ng-show="$ctrl.widget.getQueryResult().getError()">Error running query: <strong>{{$ctrl.widget.getQueryResult().getError()}}</strong></div>
</div> </div>
<div ng-switch-when="done" class="body-row-auto scrollbox" ng-style="$ctrl.getWidgetStyles()"> <div ng-switch-when="done" class="body-row-auto scrollbox">
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.queryResult" class="t-body"></visualization-renderer> <visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
</div> </div>
<div ng-switch-default class="body-row-auto spinner-container"> <div ng-switch-default class="body-row-auto spinner-container">
<div class="spinner"> <div class="spinner">
@@ -52,20 +52,20 @@
<div class="body-row clearfix tile__bottom-control"> <div class="body-row clearfix tile__bottom-control">
<a class="small hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"> <a class="small hidden-print" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span> <i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
</a> </a>
<span class="small hidden-print" ng-if="$ctrl.public"> <span class="small hidden-print" ng-if="$ctrl.public">
<i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.queryResult.getUpdatedAt()"></span> <i class="zmdi zmdi-time-restore"></i> <span am-time-ago="$ctrl.widget.getQueryResult().getUpdatedAt()"></span>
</span> </span>
<span class="visible-print"> <span class="visible-print">
<i class="zmdi zmdi-time-restore"></i> {{$ctrl.queryResult.getUpdatedAt() | dateTime}} <i class="zmdi zmdi-time-restore"></i> {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}}
</span> </span>
<button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button> <button class="btn btn-sm btn-default pull-right hidden-print btn-transparent btn__refresh" ng-click="$ctrl.reload(true)" ng-if="!$ctrl.public"><i class="zmdi zmdi-refresh"></i></button>
</div> </div>
</div> </div>
<div class="tile body-container" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type"> <div class="tile body-container widget-restricted" ng-if="$ctrl.type=='restricted'" ng-class="$ctrl.type">
<div class="t-body body-row-auto scrollbox"> <div class="t-body body-row-auto scrollbox">
<div class="text-center"> <div class="text-center">
<h1><span class="zmdi zmdi-lock"></span></h1> <h1><span class="zmdi zmdi-lock"></span></h1>
@@ -76,7 +76,7 @@
</div> </div>
</div> </div>
<div class="tile body-container" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type"> <div class="tile body-container widget-text" ng-hide="$ctrl.widget.width === 0" ng-if="$ctrl.type=='textbox'" ng-class="$ctrl.type">
<div class="body-row clearfix t-body"> <div class="body-row clearfix t-body">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"> <div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
<div class="dropdown-header"> <div class="dropdown-header">

View File

@@ -1,4 +1,3 @@
import * as _ from 'underscore';
import template from './widget.html'; import template from './widget.html';
import editTextBoxTemplate from './edit-text-box.html'; import editTextBoxTemplate from './edit-text-box.html';
import './widget.less'; import './widget.less';
@@ -19,13 +18,17 @@ const EditTextBoxComponent = {
this.saveInProgress = true; this.saveInProgress = true;
if (this.widget.new_text !== this.widget.existing_text) { if (this.widget.new_text !== this.widget.existing_text) {
this.widget.text = this.widget.new_text; this.widget.text = this.widget.new_text;
this.widget.$save().then(() => { this.widget
this.close(); .$save()
}).catch(() => { .then(() => {
toastr.error('Widget can not be updated'); this.close();
}).finally(() => { })
this.saveInProgress = false; .catch(() => {
}); toastr.error('Widget can not be updated');
})
.finally(() => {
this.saveInProgress = false;
});
} else { } else {
this.close(); this.close();
} }
@@ -47,18 +50,12 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
}); });
}; };
this.getWidgetStyles = () => {
if (_.isObject(this.widget) && _.isObject(this.widget.visualization)) {
const visualization = this.widget.visualization;
if (visualization.type === 'PIVOT') {
return { overflow: 'visible' };
}
}
};
this.localParametersDefs = () => { this.localParametersDefs = () => {
if (!this.localParameters) { if (!this.localParameters) {
this.localParameters = this.widget.getQuery().getParametersDefs().filter(p => !p.global); this.localParameters = this.widget
.getQuery()
.getParametersDefs()
.filter(p => !p.global);
} }
return this.localParameters; return this.localParameters;
}; };
@@ -71,8 +68,7 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
Events.record('delete', 'widget', this.widget.id); Events.record('delete', 'widget', this.widget.id);
this.widget.$delete((response) => { this.widget.$delete((response) => {
this.dashboard.widgets = this.dashboard.widgets this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== this.widget.id);
.filter(widget => (widget.id !== undefined) && (widget.id !== this.widget.id));
this.dashboard.version = response.version; this.dashboard.version = response.version;
if (this.deleted) { if (this.deleted) {
this.deleted({}); this.deleted({});
@@ -84,14 +80,13 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
this.reload = (force) => { this.reload = (force) => {
const maxAge = $location.search().maxAge; const maxAge = $location.search().maxAge;
this.queryResult = this.widget.getQueryResult(force, maxAge); this.widget.load(force, maxAge);
}; };
if (this.widget.visualization) { if (this.widget.visualization) {
Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true }); Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true });
Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true }); Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true });
this.query = this.widget.getQuery();
this.reload(false); this.reload(false);
this.type = 'visualization'; this.type = 'visualization';

View File

@@ -39,12 +39,26 @@
.t-header.widget { .t-header.widget {
.dropdown { .dropdown {
margin-top: -5px; margin-top: -15px;
margin-right: -5px; margin-right: -15px;
.actions { .actions {
position: static; position: static;
} }
} }
} }
.scrollbox:empty {
padding: 0 !important;
font-size: 1px !important;
}
.widget-text {
:first-child {
margin-top: 0;
}
:last-child {
margin-bottom: 0;
}
}
} }

View File

@@ -6,14 +6,14 @@
</div> </div>
<hr> <hr>
<div class="form-group" ng-class='{"has-error": (inner.input | showError), "required": field.property.required}' ng-form="inner" ng-repeat="field in fields"> <div class="form-group" ng-class='{"has-error": (inner.input | showError), "required": field.property.required}' ng-form="inner" ng-repeat="field in fields">
<label ng-if="field.property.type !== 'checkbox'" class="control-label">{{field.property.title || field.name | capitalize}}</label> <label ng-if="field.property.type !== 'checkbox'" class="control-label">{{field.property.title || field.name | toHuman}}</label>
<input name="input" type="{{field.property.type}}" class="form-control" ng-model="target.options[field.name]" ng-required="field.property.required" <input name="input" type="{{field.property.type}}" class="form-control" ng-model="target.options[field.name]" ng-required="field.property.required"
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}"> ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}">
<label ng-if="field.property.type=='checkbox'"> <label ng-if="field.property.type=='checkbox'">
<input name="input" type="{{field.property.type}}" ng-model="target.options[field.name]" ng-required="field.property.required" <input name="input" type="{{field.property.type}}" ng-model="target.options[field.name]" ng-required="field.property.required"
ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}"> ng-if="field.property.type !== 'file'" accesskey="tab" placeholder="{{field.property.default}}">
{{field.property.title || field.name | capitalize}} {{field.property.title || field.name | toHuman}}
</label> </label>
<input name="input" type="file" class="form-control" ng-model="files[field.name]" ng-required="field.property.required && !target.options[field.name]" <input name="input" type="file" class="form-control" ng-model="files[field.name]" ng-required="field.property.required && !target.options[field.name]"

View File

@@ -1,4 +1,4 @@
<td ng-class="'content-align-' + column.alignContent"> <td ng-class="'content-align-' + column.alignContent + ' display-as-' + column.displayAs">
<div ng-if="allowHTML" ng-bind-html="value"></div> <div ng-if="allowHTML" ng-bind-html="value"></div>
<div ng-if="!allowHTML" ng-bind="value"></div> <div ng-if="!allowHTML" ng-bind="value"></div>
</td> </td>

View File

@@ -1,21 +1,14 @@
import { isUndefined, isFunction } from 'underscore'; import { isFunction, extend } from 'underscore';
import { formatSimpleTemplate } from '@/lib/value-format';
const hasOwnProperty = Object.prototype.hasOwnProperty;
function trim(str) { function trim(str) {
return str.replace(/^\s+|\s+$/g, ''); return str.replace(/^\s+|\s+$/g, '');
} }
function processTags(str, data, defaultColumn) { function processTags(str, data, defaultColumn) {
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, column) => { return formatSimpleTemplate(str, extend({
if (column === '@') { '@': data[defaultColumn],
column = defaultColumn; }, data));
}
if (hasOwnProperty.call(data, column) && !isUndefined(data[column])) {
return data[column];
}
return match;
});
} }
export function renderDefault(column, row) { export function renderDefault(column, row) {

View File

@@ -3,8 +3,7 @@
<thead> <thead>
<tr> <tr>
<th ng-repeat="column in $ctrl.columns" ng-click="$ctrl.onColumnHeaderClick($event, column)" <th ng-repeat="column in $ctrl.columns" ng-click="$ctrl.onColumnHeaderClick($event, column)"
class="sortable-column" ng-class="'content-align-' + column.alignContent" class="sortable-column" ng-class="'content-align-' + column.alignContent + ' display-as-' + column.displayAs">
width="{{ ['number', 'boolean', 'datetime', 'image'].indexOf(column.displayAs) >= 0 ? '1%' : undefined }}">
<span ng-if="($ctrl.orderBy.length > 1) && ($ctrl.orderByColumnsIndex[column.name] > 0)" <span ng-if="($ctrl.orderBy.length > 1) && ($ctrl.orderByColumnsIndex[column.name] > 0)"
class="sort-order-indicator">{{ $ctrl.orderByColumnsIndex[column.name] }}</span> class="sort-order-indicator">{{ $ctrl.orderByColumnsIndex[column.name] }}</span>
<span>{{column.title}}</span> <span>{{column.title}}</span>

View File

@@ -49,4 +49,12 @@
} }
} }
} }
.display-as-number,
.display-as-boolean,
.display-as-datetime,
.display-as-image {
width: 1%;
white-space: nowrap;
}
} }

View File

@@ -1,4 +1,4 @@
<td> <td ng-class="'display-as-' + column.displayAs">
<div ng-if="!isValid" class="json-cell-invalid">{{ value }}</div> <div ng-if="!isValid" class="json-cell-invalid">{{ value }}</div>
<div ng-show="isValid" class="json-cell-valid"></div> <div ng-show="isValid" class="json-cell-valid"></div>
</td> </td>

View File

@@ -0,0 +1,33 @@
<div class="empty-state bg-white tiled" ng-if="!$ctrl.loading && $ctrl.shouldShowOnboarding()">
<div class="empty-state__summary">
<h4 ng-if="$ctrl.title">{{$ctrl.title}}</h4>
<h2 ng-if="$ctrl.icon">
<i ng-class="$ctrl.icon" aria-hidden="true"></i>
</h2>
<p>{{$ctrl.description}}</p>
</div>
<div class="empty-state__steps">
<h4>Let's get started</h4>
<ol>
<li ng-class="{done: $ctrl.dataSourceStepCompleted}">
<span ng-if="!$ctrl.isAdmin">Ask an account admin to connect a data source.</span>
<span ng-if="$ctrl.isAdmin">
<a href="data_sources">Connect</a> a Data Source
</span>
</li>
<li ng-class="{done: $ctrl.queryStepCompleted}">
<a href="queries/new">Create</a> your first Query</li>
<li ng-if="$ctrl.showAlertStep" ng-class="{done: $ctrl.alertStepCompleted}">
<a href="alerts/new">Create</a> your first Alert</li>
<li ng-if="$ctrl.showDashboardStep" ng-class="{done: $ctrl.dashboardStepCompleted}">
<a ng-click="$ctrl.newDashboard()">Create</a> your first Dashboard</li>
<li ng-if="$ctrl.showInviteStep" ng-class="{done: $ctrl.inviteStepCompleted}">
<a href="users/new">Invite</a> your team members</li>
</ol>
<p>Need more support?
<a href="{{$ctrl.helpLink}}" target="_blank">See our Help
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,62 @@
import './empty-state.less';
import template from './empty-state.html';
const EmptyStateComponent = {
template,
replace: true,
bindings: {
icon: '@',
title: '@',
description: '@',
helpLink: '@',
showAlertStep: '<',
showDashboardStep: '<',
showInviteStep: '<',
onboardingMode: '<',
},
controller($http, $uibModal, currentUser) {
this.loading = true;
this.isAdmin = currentUser.isAdmin;
$http.get('api/organization/status').then((response) => {
this.loading = false;
const counters = response.data.object_counters;
this.dataSourceStepCompleted = counters.data_sources > 0;
this.queryStepCompleted = counters.queries > 0;
this.dashboardStepCompleted = counters.dashboards > 0;
this.alertStepCompleted = counters.alerts > 0;
this.inviteStepCompleted = counters.users > 1;
});
this.shouldShowOnboarding = () => {
if (this.loading) {
return false;
}
if (!this.onboardingMode) {
return true;
}
return !(
this.dataSourceStepCompleted &&
this.queryStepCompleted &&
this.dashboardStepCompleted &&
this.inviteStepCompleted
);
};
this.newDashboard = () => {
$uibModal.open({
component: 'editDashboardDialog',
resolve: {
dashboard: () => ({ name: null, layout: null }),
},
});
};
},
};
export default function init(ngModule) {
ngModule.component('emptyState', EmptyStateComponent);
}

View File

@@ -0,0 +1,56 @@
// Empty states
.empty-state {
width: 100%;
margin: 0px auto 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 35px;
font-size: 14px;
line-height: 21px;
.empty-state__summary,
.empty-state__steps {
width: 48%;
}
.empty-state__summary {
align-self: flex-start;
}
ol {
margin-bottom: 15px;
padding: 17px;
}
li.done {
text-decoration: line-through;
}
h2 {
margin: 0 0 15px;
}
h4 {
margin-top: 0;
color: #767676;
margin-bottom: 15px;
}
p {
margin-bottom: 0;
}
@media (max-width: 767px) {
flex-direction: column;
.empty-state__summary {
margin-bottom: 25px;
}
.empty-state__summary,
.empty-state__steps {
width: 100%;
}
}
}

View File

@@ -1,5 +1,6 @@
import moment from 'moment'; import moment from 'moment';
import { map, range, partial } from 'underscore'; import { map, range, partial } from 'underscore';
import { durationHumanize } from '@/filters';
import template from './schedule-dialog.html'; import template from './schedule-dialog.html';
@@ -57,7 +58,7 @@ function queryTimePicker() {
}; };
} }
function queryRefreshSelect() { function queryRefreshSelect(clientConfig) {
return { return {
restrict: 'E', restrict: 'E',
scope: { scope: {
@@ -73,43 +74,8 @@ function queryRefreshSelect() {
<option value="">No Refresh</option> <option value="">No Refresh</option>
</select>`, </select>`,
link($scope) { link($scope) {
$scope.refreshOptions = [ $scope.refreshOptions =
{ clientConfig.queryRefreshIntervals.map(interval => ({ value: String(interval), name: `Every ${durationHumanize(interval)}` }));
value: '60',
name: 'Every minute',
},
];
[5, 10, 15, 30].forEach((i) => {
$scope.refreshOptions.push({
value: String(i * 60),
name: `Every ${i} minutes`,
});
});
range(1, 13).forEach((i) => {
$scope.refreshOptions.push({
value: String(i * 3600),
name: `Every ${i}h`,
});
});
$scope.refreshOptions.push({
value: String(24 * 3600),
name: 'Every 24h',
});
$scope.refreshOptions.push({
value: String(7 * 24 * 3600),
name: 'Every 7 days',
});
$scope.refreshOptions.push({
value: String(14 * 24 * 3600),
name: 'Every 14 days',
});
$scope.refreshOptions.push({
value: String(30 * 24 * 3600),
name: 'Every 30 days',
});
$scope.$watch('refreshType', () => { $scope.$watch('refreshType', () => {
if ($scope.refreshType === 'periodic') { if ($scope.refreshType === 'periodic') {

View File

@@ -1,33 +1,15 @@
const dashboardGridOptions = { const dashboardGridOptions = {
columns: 6, columns: 6, // grid columns count
pushing: true, rowHeight: 50, // grid row height (incl. bottom padding)
floating: true, margins: 15, // widget margins
swapping: true,
width: 'auto',
colWidth: 'auto',
rowHeight: 50,
margins: [15, 15],
outerMargin: false,
sparse: false,
isMobile: false,
mobileBreakPoint: 800, mobileBreakPoint: 800,
mobileModeEnabled: true, // defaults for widgets
minColumns: 1,
minRows: 1,
maxRows: 100,
defaultSizeX: 3, defaultSizeX: 3,
defaultSizeY: 3, defaultSizeY: 3,
minSizeX: 1, minSizeX: 1,
maxSizeX: null, maxSizeX: 6,
minSizeY: 4, minSizeY: 1,
maxSizeY: null, maxSizeY: 1000,
resizable: {
enabled: false,
handles: ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'],
},
draggable: {
enabled: false,
},
}; };
export default function init(ngModule) { export default function init(ngModule) {

View File

@@ -17,7 +17,6 @@ import 'angular-moment';
import 'brace'; import 'brace';
import 'angular-ui-ace'; import 'angular-ui-ace';
import 'angular-resizable'; import 'angular-resizable';
import ngGridster from 'angular-gridster';
import { each, isFunction } from 'underscore'; import { each, isFunction } from 'underscore';
import '@/lib/sortable'; import '@/lib/sortable';
@@ -52,7 +51,6 @@ const requirements = [
'angularResizable', 'angularResizable',
vsRepeat, vsRepeat,
'ui.sortable', 'ui.sortable',
ngGridster.name,
]; ];
const ngModule = angular.module('app', requirements); const ngModule = angular.module('app', requirements);

View File

@@ -3,7 +3,6 @@ import 'font-awesome/css/font-awesome.css';
import 'ui-select/dist/select.css'; import 'ui-select/dist/select.css';
import 'angular-toastr/dist/angular-toastr.css'; import 'angular-toastr/dist/angular-toastr.css';
import 'angular-resizable/src/angular-resizable.css'; import 'angular-resizable/src/angular-resizable.css';
import 'angular-gridster/dist/angular-gridster.css';
import 'pace-progress/themes/blue/pace-theme-minimal.css'; import 'pace-progress/themes/blue/pace-theme-minimal.css';
import '@/assets/css/superflat_redash.css'; import '@/assets/css/superflat_redash.css';

View File

@@ -1,57 +0,0 @@
import * as _ from 'underscore';
import { requestAnimationFrame } from './utils';
function gridsterAutoHeight($timeout) {
return {
restrict: 'A',
require: 'gridsterItem',
link($scope, $element, attr, controller) {
let destroyed = false;
function updateHeight() {
const wrapper = $element[0];
// Query element, but keep selector order
const element = _.chain(attr.gridsterAutoHeight.split(','))
.map(selector => wrapper.querySelector(selector))
.filter(_.isObject)
.first()
.value();
if (element) {
const childrenBounds = _.chain(element.children)
.map(child => child.getBoundingClientRect())
.reduce((result, bounds) => ({
left: Math.min(result.left, bounds.left),
top: Math.min(result.top, bounds.top),
right: Math.min(result.right, bounds.right),
bottom: Math.min(result.bottom, bounds.bottom),
}))
.value();
const additionalHeight = 100 + _.last(controller.gridster.margins);
const contentsHeight = childrenBounds.bottom - childrenBounds.top;
$timeout(() => {
controller.sizeY = Math.ceil((contentsHeight + additionalHeight) /
controller.gridster.curRowHeight);
});
}
if (!destroyed) {
requestAnimationFrame(updateHeight);
}
}
if (controller.sizeY < 0) {
$element.addClass('gridster-auto-height-enabled');
updateHeight();
$scope.$on('$destroy', () => {
destroyed = true;
});
}
},
};
}
export default function init(ngModule) {
ngModule.directive('gridsterAutoHeight', gridsterAutoHeight);
}

View File

@@ -1,6 +1,5 @@
import autofocus from './autofocus'; import autofocus from './autofocus';
import compareTo from './compare-to'; import compareTo from './compare-to';
import gridsterAutoHeight from './gridster-auto-height';
import title from './title'; import title from './title';
import resizeEvent from './resize-event'; import resizeEvent from './resize-event';
import resizableToggle from './resizable-toggle'; import resizableToggle from './resizable-toggle';
@@ -8,7 +7,6 @@ import resizableToggle from './resizable-toggle';
export default function init(ngModule) { export default function init(ngModule) {
autofocus(ngModule); autofocus(ngModule);
compareTo(ngModule); compareTo(ngModule);
gridsterAutoHeight(ngModule);
title(ngModule); title(ngModule);
resizeEvent(ngModule); resizeEvent(ngModule);
resizableToggle(ngModule); resizableToggle(ngModule);

View File

@@ -1,9 +1,13 @@
import { find } from 'underscore';
function sameNumber(a, b) { function sameNumber(a, b) {
return (isNaN(a) && isNaN(b)) || (a === b); return (isNaN(a) && isNaN(b)) || (a === b);
} }
const flexBasis = ['flexBasis', 'webkitFlexBasis', 'msFlexPreferredSize'] const flexBasis = find(
.find(prop => prop in document.documentElement.style) || 'flexBasis'; ['flexBasis', 'webkitFlexBasis', 'msFlexPreferredSize'],
prop => prop in document.documentElement.style,
) || 'flexBasis';
const threshold = 5; const threshold = 5;

View File

@@ -1,5 +1,4 @@
import * as _ from 'underscore'; import * as _ from 'underscore';
import { requestAnimationFrame } from './utils';
const items = new Map(); const items = new Map();
@@ -18,7 +17,7 @@ function checkItems() {
} }
}); });
requestAnimationFrame(checkItems); setTimeout(checkItems, 50);
} }
checkItems(); // ensure it was called only once! checkItems(); // ensure it was called only once!

View File

@@ -127,3 +127,11 @@ export function prettySize(bytes) {
return bytes.toFixed(3) + ' ' + units[unit]; return bytes.toFixed(3) + ' ' + units[unit];
} }
export function join(arr) {
if (arr === undefined || arr === null) {
return '';
}
return arr.join(' / ');
}

View File

@@ -5,6 +5,8 @@ import _ from 'underscore';
// eslint-disable-next-line // eslint-disable-next-line
const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi; const urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
const hasOwnProperty = Object.prototype.hasOwnProperty;
function createDefaultFormatter(highlightLinks) { function createDefaultFormatter(highlightLinks) {
if (highlightLinks) { if (highlightLinks) {
return (value) => { return (value) => {
@@ -50,7 +52,7 @@ function createNumberFormatter(format) {
return value => value; return value => value;
} }
export default function createFormatter(column) { export function createFormatter(column) {
switch (column.displayAs) { switch (column.displayAs) {
case 'number': return createNumberFormatter(column.numberFormat); case 'number': return createNumberFormatter(column.numberFormat);
case 'boolean': return createBooleanFormatter(column.booleanValues); case 'boolean': return createBooleanFormatter(column.booleanValues);
@@ -58,3 +60,15 @@ export default function createFormatter(column) {
default: return createDefaultFormatter(column.allowHTML && column.highlightLinks); default: return createDefaultFormatter(column.allowHTML && column.highlightLinks);
} }
} }
export function formatSimpleTemplate(str, data) {
if (!_.isString(str)) {
return '';
}
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, prop) => {
if (hasOwnProperty.call(data, prop) && !_.isUndefined(data[prop])) {
return data[prop];
}
return match;
});
}

View File

@@ -1,8 +1,13 @@
<div class="container"> <div class="container">
<page-header title="Alerts"> <page-header title="Alerts"></page-header>
</page-header>
<div class="bg-white tiled"> <empty-state icon="fa fa-bell-o"
description="Get notified on certain events"
show-alert-step="true"
help-link="http://help.redash.io/category/23-alerts"
ng-if="$ctrl.showEmptyState"></empty-state>
<div class="bg-white tiled" ng-if="$ctrl.showList">
<table class="table table-condensed table-hover"> <table class="table table-condensed table-hover">
<thead> <thead>
<tr> <tr>

View File

@@ -11,8 +11,17 @@ class AlertsListCtrl {
constructor(Events, Alert) { constructor(Events, Alert) {
Events.record('view', 'page', 'alerts'); Events.record('view', 'page', 'alerts');
this.showEmptyState = false;
this.showList = false;
this.alerts = new Paginator([], { itemsPerPage: 20 }); this.alerts = new Paginator([], { itemsPerPage: 20 });
Alert.query((alerts) => { Alert.query((alerts) => {
if (alerts.length > 0) {
this.showList = true;
} else {
this.showEmptyState = true;
}
this.alerts.updateRows(alerts.map(alert => ({ this.alerts.updateRows(alerts.map(alert => ({
id: alert.id, id: alert.id,
name: alert.name, name: alert.name,

View File

@@ -1,7 +1,13 @@
<div class='container'> <div class='container'>
<page-header title="Dashboards"></page-header> <page-header title="Dashboards"></page-header>
<div class="row"> <empty-state icon="zmdi zmdi-view-quilt"
description="See the big picture"
show-dashboard-step="true"
help-link="http://help.redash.io/category/22-dashboards"
ng-if="$ctrl.showEmptyState"></empty-state>
<div class="row" ng-if="$ctrl.showList">
<div class="col-lg-3"> <div class="col-lg-3">
<input type='text' class='form-control' placeholder="Search Dashboards..." <input type='text' class='form-control' placeholder="Search Dashboards..."
ng-change="$ctrl.update()" ng-model="$ctrl.searchText" autofocus/> ng-change="$ctrl.update()" ng-model="$ctrl.searchText" autofocus/>

View File

@@ -35,7 +35,15 @@ function DashboardListCtrl(Dashboard, $location) {
}; };
this.allTags = []; this.allTags = [];
this.showList = false;
this.showEmptyState = false;
this.dashboards.$promise.then((data) => { this.dashboards.$promise.then((data) => {
if (data.length > 0) {
this.showList = true;
} else {
this.showEmptyState = true;
}
const out = data.map(dashboard => dashboard.name.match(TAGS_REGEX)); const out = data.map(dashboard => dashboard.name.match(TAGS_REGEX));
this.allTags = _.unique(_.flatten(out)).filter(e => e).map(tag => tag.replace(/:$/, '')); this.allTags = _.unique(_.flatten(out)).filter(e => e).map(tag => tag.replace(/:$/, ''));
this.allTags.sort(); this.allTags.sort();

View File

@@ -84,12 +84,16 @@
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange(filter, $modal)"></filters> <filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange(filter, $modal)"></filters>
</div> </div>
<div style="overflow: hidden; padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0"> <div style="padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper" <div gridstack editing="$ctrl.layoutEditing && !$ctrl.saveInProgress" batch-update="$ctrl.updateGridItems"
is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper"
ng-class="{'preview-mode': !$ctrl.layoutEditing, 'editing-mode': $ctrl.layoutEditing}"> ng-class="{'preview-mode': !$ctrl.layoutEditing, 'editing-mode': $ctrl.layoutEditing}">
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position" <div class="dashboard-widget-wrapper"
gridster-auto-height=".scrollbox, .spinner-container"> ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id"
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget> gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
<div class="grid-stack-item-content">
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,22 @@ import template from './dashboard.html';
import shareDashboardTemplate from './share-dashboard.html'; import shareDashboardTemplate from './share-dashboard.html';
import './dashboard.less'; import './dashboard.less';
function isWidgetPositionChanged(oldPosition, newPosition) {
const fields = ['col', 'row', 'sizeX', 'sizeY', 'autoHeight'];
oldPosition = _.pick(oldPosition, fields);
newPosition = _.pick(newPosition, fields);
return !!_.find(fields, key => newPosition[key] !== oldPosition[key]);
}
function getWidgetsWithChangedPositions(widgets) {
return _.filter(widgets, (widget) => {
if (!_.isObject(widget.$originalPosition)) {
return true;
}
return isWidgetPositionChanged(widget.$originalPosition, widget.options.position);
});
}
function DashboardCtrl( function DashboardCtrl(
$rootScope, $rootScope,
$routeParams, $routeParams,
@@ -18,26 +34,27 @@ function DashboardCtrl(
currentUser, currentUser,
clientConfig, clientConfig,
Events, Events,
dashboardGridOptions,
toastr, toastr,
) { ) {
this.saveInProgress = false; this.saveInProgress = false;
const saveDashboardLayout = () => {
const saveDashboardLayout = (widgets) => {
if (!this.dashboard.canEdit()) { if (!this.dashboard.canEdit()) {
return; return;
} }
this.saveInProgress = true; this.saveInProgress = true;
const showMessages = true; // this.layoutEditing; const showMessages = true;
// Temporarily disable grid editing (but allow user to use UI controls)
this.dashboardGridOptions.draggable.enabled = false;
this.dashboardGridOptions.resizable.enabled = false;
return $q return $q
.all(_.map(this.dashboard.widgets, widget => widget.$save())) .all(_.map(widgets, widget => widget.$save()))
.then(() => { .then(() => {
if (showMessages) { if (showMessages) {
toastr.success('Changes saved.'); toastr.success('Changes saved.');
} }
// Update original widgets positions
_.each(widgets, (widget) => {
_.extend(widget.$originalPosition, widget.options.position);
});
}) })
.catch(() => { .catch(() => {
if (showMessages) { if (showMessages) {
@@ -46,51 +63,28 @@ function DashboardCtrl(
}) })
.finally(() => { .finally(() => {
this.saveInProgress = false; this.saveInProgress = false;
// If user didn't disable editing mode while saving - restore grid
this.dashboardGridOptions.draggable.enabled = this.layoutEditing;
this.dashboardGridOptions.resizable.enabled = this.layoutEditing;
}); });
}; };
this.layoutEditing = false; this.layoutEditing = false;
this.dashboardGridOptions = _.extend({}, dashboardGridOptions, {
resizable: {
enabled: false,
handles: ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'],
},
draggable: {
enabled: false,
},
});
this.isFullscreen = false; this.isFullscreen = false;
this.refreshRate = null; this.refreshRate = null;
this.isGridDisabled = false; this.isGridDisabled = false;
this.updateGridItems = null;
this.showPermissionsControl = clientConfig.showPermissionsControl; this.showPermissionsControl = clientConfig.showPermissionsControl;
this.globalParameters = []; this.globalParameters = [];
this.refreshRates = [
{ name: '10 seconds', rate: 10 },
{ name: '30 seconds', rate: 30 },
{ name: '1 minute', rate: 60 },
{ name: '5 minutes', rate: 60 * 5 },
{ name: '10 minutes', rate: 60 * 10 },
{ name: '30 minutes', rate: 60 * 30 },
{ name: '1 hour', rate: 60 * 60 },
{ name: '12 hour', rate: 12 * 60 * 60 },
{ name: '24 hour', rate: 24 * 60 * 60 },
];
this.refreshRates = this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
clientConfig.dashboardRefreshIntervals.map(interval => ({ name: durationHumanize(interval), rate: interval })); name: durationHumanize(interval),
rate: interval,
}));
$rootScope.$on('gridster-mobile-changed', ($event, gridster) => { this.setRefreshRate = (rate, load = true) => {
this.isGridDisabled = gridster.isMobile;
});
this.setRefreshRate = (rate) => {
this.refreshRate = rate; this.refreshRate = rate;
if (rate !== null) { if (rate !== null) {
this.loadDashboard(true); if (load) {
this.loadDashboard(true);
}
this.autoRefresh(); this.autoRefresh();
} }
}; };
@@ -124,8 +118,7 @@ function DashboardCtrl(
}; };
const collectFilters = (dashboard, forceRefresh) => { const collectFilters = (dashboard, forceRefresh) => {
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.getQueryResult(forceRefresh))) const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.loadPromise(forceRefresh)));
.map(queryResult => queryResult.toPromise());
$q.all(queryResultPromises).then((queryResults) => { $q.all(queryResultPromises).then((queryResults) => {
const filters = {}; const filters = {};
@@ -171,9 +164,10 @@ function DashboardCtrl(
}; };
this.loadDashboard = _.throttle((force) => { this.loadDashboard = _.throttle((force) => {
this.dashboard = Dashboard.get( Dashboard.get(
{ slug: $routeParams.dashboardSlug }, { slug: $routeParams.dashboardSlug },
(dashboard) => { (dashboard) => {
this.dashboard = dashboard;
Events.record('view', 'dashboard', dashboard.id); Events.record('view', 'dashboard', dashboard.id);
renderDashboard(dashboard, force); renderDashboard(dashboard, force);
@@ -181,6 +175,20 @@ function DashboardCtrl(
$location.search('edit', null); $location.search('edit', null);
this.editLayout(true); this.editLayout(true);
} }
if ($location.search().refresh !== undefined) {
if (this.refreshRate === null) {
const refreshRate = Math.max(30, parseFloat($location.search().refresh));
this.setRefreshRate(
{
name: durationHumanize(refreshRate),
rate: refreshRate,
},
false,
);
}
}
}, },
(rejection) => { (rejection) => {
const statusGroup = Math.floor(rejection.status / 100); const statusGroup = Math.floor(rejection.status / 100);
@@ -230,34 +238,25 @@ function DashboardCtrl(
this.editLayout = (enableEditing, applyChanges) => { this.editLayout = (enableEditing, applyChanges) => {
if (!this.isGridDisabled) { if (!this.isGridDisabled) {
if (enableEditing) { if (!enableEditing) {
if (!this.layoutEditing) {
// Save current positions of widgets
_.each(this.dashboard.widgets, (widget) => {
widget.$savedPosition = _.clone(widget.options.position);
});
}
} else {
if (applyChanges) { if (applyChanges) {
// Clear saved data and save layout const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
_.each(this.dashboard.widgets, (widget) => { saveDashboardLayout(changedWidgets);
widget.$savedPosition = undefined;
});
saveDashboardLayout();
} else { } else {
// Revert changes // Revert changes
const items = {};
_.each(this.dashboard.widgets, (widget) => { _.each(this.dashboard.widgets, (widget) => {
if (_.isObject(widget.$savedPosition)) { _.extend(widget.options.position, widget.$originalPosition);
widget.options.position = widget.$savedPosition; items[widget.id] = widget.options.position;
}
widget.$savedPosition = undefined;
}); });
this.dashboard.widgets = Dashboard.prepareWidgetsForDashboard(this.dashboard.widgets);
if (this.updateGridItems) {
this.updateGridItems(items);
}
} }
} }
this.layoutEditing = enableEditing; this.layoutEditing = enableEditing;
this.dashboardGridOptions.draggable.enabled = this.layoutEditing && !this.saveInProgress;
this.dashboardGridOptions.resizable.enabled = this.layoutEditing && !this.saveInProgress;
} }
}; };
@@ -297,19 +296,10 @@ function DashboardCtrl(
}) })
.result.then(() => { .result.then(() => {
this.extractGlobalParameters(); this.extractGlobalParameters();
if (this.layoutEditing) { // Save position of newly added widget (but not entire layout)
// Save position of newly added widget (but not entire layout) const widget = _.last(this.dashboard.widgets);
const widget = _.last(this.dashboard.widgets); if (_.isObject(widget)) {
if (_.isObject(widget)) { return widget.$save();
return widget.$save().then(() => {
if (this.layoutEditing) {
widget.$savedPosition = _.clone(widget.options.position);
}
});
}
} else {
// Update entire layout
return saveDashboardLayout();
} }
}); });
}; };
@@ -317,10 +307,10 @@ function DashboardCtrl(
this.removeWidget = () => { this.removeWidget = () => {
this.extractGlobalParameters(); this.extractGlobalParameters();
if (!this.layoutEditing) { if (!this.layoutEditing) {
// We need to wait a bit for `angular-gridster` before it updates widgets, // We need to wait a bit while `angular` updates widgets, and only then save new layout
// and only then save new layout
$timeout(() => { $timeout(() => {
saveDashboardLayout(); const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
saveDashboardLayout(changedWidgets);
}, 50); }, 50);
} }
}; };

View File

@@ -1,9 +1,16 @@
.dashboard-wrapper { .dashboard-wrapper {
.tile { .tile {
display: flex; display: flex;
position: static; position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: auto;
height: auto; height: auto;
margin-bottom: 15px; overflow: hidden;
margin: 0;
padding: 0;
} }
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div { pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
@@ -28,21 +35,30 @@
} }
} }
.gridster-preview-holder { .dashboard-widget-wrapper:not(.widget-auto-height-enabled) {
background: #aaa; visualization-renderer {
} display: flex;
flex-direction: column;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
&.gridster-mobile { > filters {
margin: 0; flex-grow: 0;
}
.gridster-item { > div {
margin-left: 0 !important; flex-grow: 1;
margin-right: 0 !important; position: relative;
}
} }
}
&:not(.gridster-mobile) { .sunburst-visualization-container,
.tile { .sankey-visualization-container,
.map-visualization-container,
.plotly-chart-container {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@@ -51,60 +67,27 @@
width: auto; width: auto;
height: auto; height: auto;
overflow: hidden; overflow: hidden;
margin: 0;
padding: 0;
} }
.gridster-item:not(.gridster-auto-height-enabled) { counter {
visualization-renderer { position: absolute;
display: flex; left: 10px;
flex-direction: column; top: 15px;
position: absolute; right: 10px;
left: 0; bottom: 15px;
top: 0; height: auto;
right: 0; overflow: hidden;
bottom: 0; padding: 0;
> filters {
flex-grow: 0;
}
> div {
flex-grow: 1;
position: relative;
}
}
.sunburst-visualization-container,
.sankey-visualization-container,
.map-visualization-container,
.plotly-chart-container {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: auto;
height: auto;
overflow: hidden;
}
counter {
position: absolute;
left: 10px;
top: 15px;
right: 10px;
bottom: 15px;
height: auto;
overflow: hidden;
padding: 0;
}
} }
} }
.gridster-auto-height-enabled { .widget-auto-height-enabled {
.spinner { .spinner {
position: static; position: static;
} }
.scrollbox {
overflow-y: hidden;
}
} }
} }

View File

@@ -1,4 +1,4 @@
<div class="container m-t-10 m-b-20"> <div class="container p-t-10 p-b-20">
<page-header title="{{$ctrl.dashboard.name}}"> <page-header title="{{$ctrl.dashboard.name}}">
</page-header> </page-header>
@@ -6,11 +6,14 @@
<filters ng-if="$ctrl.dashboard.dashboard_filters_enabled"></filters> <filters ng-if="$ctrl.dashboard.dashboard_filters_enabled"></filters>
</div> </div>
<div style="overflow: hidden"> <div style="padding-bottom: 5px" ng-if="$ctrl.dashboard.widgets.length > 0">
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper"> <div gridstack editing="false" class="dashboard-wrapper preview-mode">
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position" <div class="dashboard-widget-wrapper"
gridster-auto-height=".scrollbox, .spinner-container"> ng-repeat="widget in $ctrl.dashboard.widgets"
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" public="true"></dashboard-widget> gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
<div class="grid-stack-item-content">
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" public="true"></dashboard-widget>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ export default function init(ngModule) {
permission: 'admin', permission: 'admin',
title: 'Alert Destinations', title: 'Alert Destinations',
path: 'destinations', path: 'destinations',
order: 4,
}); });
ngModule.controller('DestinationsCtrl', DestinationsCtrl); ngModule.controller('DestinationsCtrl', DestinationsCtrl);

View File

@@ -1,4 +1,10 @@
<div class="container"> <div class="container">
<empty-state title="Welcome to Redash 👋"
description="Connect to any data source, easily visualize and share your data"
show-dashboard-step="true"
show-invite-step="true"
onboarding-mode="true"
help-link="http://help.redash.io/article/32-getting-started"></empty-state>
<div class="tile"> <div class="tile">
<div class="t-body tb-padding"> <div class="t-body tb-padding">

View File

@@ -50,6 +50,9 @@ class QueriesListCtrl {
{ name: 'My Queries', path: 'queries/my' }, { name: 'My Queries', path: 'queries/my' },
{ name: 'Search', path: 'queries/search' }, { name: 'Search', path: 'queries/search' },
]; ];
this.showList = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length > 0;
this.showEmptyState = () => this.paginator.getPageRows() !== undefined && this.paginator.getPageRows().length === 0;
} }
} }

View File

@@ -1,33 +1,41 @@
<div class="container"> <div class="container">
<page-header title="Queries"></page-header> <page-header title="Queries"></page-header>
<tab-nav tabs="$ctrl.tabs"></tab-nav>
<div class="bg-white tiled"> <empty-state icon="fa fa-code" description="Getting the data from your datasources." help-link="http://help.redash.io/category/21-querying"
<table class="table table-condensed table-hover"> ng-if="$ctrl.showEmptyState()"></empty-state>
<thead>
<tr> <div ng-if="$ctrl.showList()">
<th>Name</th> <tab-nav tabs="$ctrl.tabs"></tab-nav>
<th>Created By</th>
<th>Created At</th> <div class="bg-white tiled">
<th>Runtime</th> <table class="table table-condensed table-hover">
<th>Last Executed At</th> <thead>
<th>Update Schedule</th> <tr>
</tr> <th>Name</th>
</thead> <th>Created By</th>
<tbody> <th>Created At</th>
<tr ng-repeat="query in $ctrl.paginator.getPageRows()"> <th>Runtime</th>
<td><a href="queries/{{query.id}}">{{query.name}}</a> <span class="label label-default" ng-if="query.is_draft">Unpublished</span></td> <th>Last Executed At</th>
<td> <th>Update Schedule</th>
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/> </tr>
{{query.user.name}} </thead>
</td> <tbody>
<td>{{query.created_at | dateTime}}</td> <tr ng-repeat="query in $ctrl.paginator.getPageRows()">
<td>{{query.runtime | durationHumanize}}</td> <td>
<td>{{query.retrieved_at | dateTime}}</td> <a href="queries/{{query.id}}">{{query.name}}</a>
<td>{{query.schedule | scheduleHumanize}}</td> <span class="label label-default" ng-if="query.is_draft">Unpublished</span>
</tr> </td>
</tbody> <td>
</table> <img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb" /> {{query.user.name}}
<paginator paginator="$ctrl.paginator"></paginator> </td>
<td>{{query.created_at | dateTime}}</td>
<td>{{query.runtime | durationHumanize}}</td>
<td>{{query.retrieved_at | dateTime}}</td>
<td>{{query.schedule | scheduleHumanize}}</td>
</tr>
</tbody>
</table>
<paginator paginator="$ctrl.paginator"></paginator>
</div>
</div> </div>
</div> </div>

View File

@@ -118,119 +118,123 @@
</nav> </nav>
<div class="content"> <div class="content">
<div class="flex-fill d-flex flex-column p-l-15 p-r-15"> <div class="flex-fill p-relative">
<div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle <div class="p-absolute d-flex flex-column p-l-15 p-r-15" style="left: 0; top: 0; right: 0; bottom: 0;">
style="min-height: 11px; max-height: 460px;" ng-if="sourceMode"> <div class="row editor" resizable r-directions="['bottom']" r-flex="true" resizable-toggle
<section> style="min-height: 11px; max-height: 460px;" ng-if="sourceMode">
<section>
<div class="container p-15 m-b-10" style="height:100%;"> <div class="container p-15 m-b-10" style="height:100%;">
<p style="height:calc(100% - 40px); margin-bottom: 0px;" class="editor__container"> <p style="height:calc(100% - 40px); margin-bottom: 0px;" class="editor__container">
<query-editor query="query" <query-editor query="query"
schema="schema" schema="schema"
syntax="dataSource.syntax"></query-editor> syntax="dataSource.syntax"></query-editor>
<button type="button" class="btn btn-default btn-s btn__format pull-right" ng-click="formatQuery()" title="Format"> <button type="button" class="btn btn-default btn-s btn__format pull-right" ng-click="formatQuery()" title="Format">
<span class="zmdi zmdi-format-indent-increase"></span> <span class="zmdi zmdi-format-indent-increase"></span>
</button> </button>
</p> </p>
<div class="editor__control"> <div class="editor__control">
<div class="row form-inline"> <div class="row form-inline">
<div class="col-xs-5 text-left"> <div class="col-xs-5 text-left">
<select class="form-control datasource-small" ng-disabled="!isQueryOwner || !sourceMode" ng-model="query.data_source_id" ng-change="updateDataSource()" <select class="form-control datasource-small" ng-disabled="!isQueryOwner || !sourceMode" ng-model="query.data_source_id" ng-change="updateDataSource()"
ng-options="ds.id as ds.name for ds in dataSources"></select> ng-options="ds.id as ds.name for ds in dataSources"></select>
</div>
<div class="col-xs-7">
<div class="editor__control--right">
<button class="btn btn-default" ng-show="canEdit" ng-click="saveQuery()" title="Save">
<span class="fa fa-floppy-o"></span>
<span class="hidden-xs">Save</span>
<span
ng-show="isDirty">&#42;</span>
</button>
<button type="button" class="btn btn-primary" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()">
<span class="zmdi zmdi-play"></span>
<span class="hidden-xs">Execute</span>
</button>
</div> </div>
<div class="col-xs-7">
<div class="editor__control--right">
<button class="btn btn-default" ng-show="canEdit" ng-click="saveQuery()" title="Save">
<span class="fa fa-floppy-o"></span>
<span class="hidden-xs">Save</span>
<span
ng-show="isDirty">&#42;</span>
</button>
<button type="button" class="btn btn-primary" ng-disabled="queryExecuting || !canExecuteQuery()" ng-click="executeQuery()">
<span class="zmdi zmdi-play"></span>
<span class="hidden-xs">Execute</span>
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="row query-metadata__mobile">
<div class="col-xs-4 text-left">
<span class="m-r-5">Created by</span>
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.created_at"></rd-time-ago></strong>
</div>
<div class="col-xs-4 text-center">
<span class="m-r-5">Updated by</span>
<img ng-src="{{query.last_modified_by.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.updated_at"></rd-time-ago></strong>
</div>
<div class="col-xs-4 text-right">
<span class="query-metadata__property"></span> Refresh Schedule</span>
<a ng-click="openScheduleForm()" ng-if="!query.isNew()">{{query.schedule | scheduleHumanize}}</a>
<span ng-if="query.isNew()">Never</span>
</div>
</div>
<section class="flex-fill p-relative t-body">
<div class="d-flex flex-column p-b-15 p-absolute" style="left: 0; top: 0; right: 0; bottom: 0;">
<div class="p-t-15 p-b-15" ng-if="query.getParametersDefs().length > 0">
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
</div>
<!-- Query Execution Status -->
<div class="query-alerts">
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'processing'">
Executing query&hellip;
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
ng-click="cancelExecution()">Cancel
</button>
</div>
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'waiting'">
Query in queue&hellip;
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
ng-click="cancelExecution()">Cancel
</button>
</div>
<div class="alert alert-danger m-t-15" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong>
</div>
</div>
<!-- End of Query Execution Status -->
<!-- tabs and data -->
<div ng-if="showDataset" class="flex-fill p-relative">
<div class="d-flex flex-column p-absolute" style="left: 0; top: 0; right: 0; bottom: 0;">
<div class="p-10" ng-show="showLog">
<p>Log Information:</p>
<p ng-repeat="l in queryResult.getLog()">{{l}}</p>
</div>
<ul class="tab-nav">
<rd-tab ng-if="!query.visualizations.length" tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" base-path="query.getUrl(sourceMode)" ng-repeat="vis in query.visualizations | orderBy:'id'">
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-if="canEdit && !($first && (vis.type === 'TABLE'))"> &times;</span>
</rd-tab>
<li class="rd-tab"><a ng-click="openVisualizationEditor()" ng-if="sourceMode && canEdit">&plus; New Visualization</a></li>
</ul>
<div ng-if="!query.visualizations.length" class="query__vis m-t-15 p-b-15 scrollbox">
<filters filters="filters"></filters>
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
</div> </div>
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations" class="query__vis m-t-15 scrollbox">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<div class="row query-metadata__mobile">
<div class="col-xs-4 text-left">
<span class="m-r-5">Created by</span>
<img ng-src="{{query.user.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.created_at"></rd-time-ago></strong>
</div>
<div class="col-xs-4 text-center">
<span class="m-r-5">Updated by</span>
<img ng-src="{{query.last_modified_by.profile_image_url}}" class="profile__image_thumb"/> <strong><rd-time-ago value="query.updated_at"></rd-time-ago></strong>
</div>
<div class="col-xs-4 text-right">
<span class="query-metadata__property"></span> Refresh Schedule</span>
<a ng-click="openScheduleForm()" ng-if="!query.isNew()">{{query.schedule | scheduleHumanize}}</a>
<span ng-if="query.isNew()">Never</span>
</div>
</div>
<section class="flex-fill d-flex flex-column">
<div class="t-body p-b-15 flex-fill d-flex flex-column">
<div class="p-t-15 p-b-15" ng-if="query.getParametersDefs().length > 0">
<parameters parameters="query.getParametersDefs()" sync-values="!query.isNew()" editable="sourceMode && canEdit"></parameters>
</div>
<!-- Query Execution Status -->
<div class="query-alerts">
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'processing'">
Executing query&hellip;
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
ng-click="cancelExecution()">Cancel
</button>
</div>
<div class="alert alert-info m-t-15" ng-show="queryResult.getStatus() == 'waiting'">
Query in queue&hellip;
<rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling"
ng-click="cancelExecution()">Cancel
</button>
</div>
<div class="alert alert-danger m-t-15" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong>
</div>
</div>
<!-- End of Query Execution Status -->
<!-- tabs and data -->
<div ng-if="showDataset" class="flex-fill d-flex flex-column">
<div class="p-10" ng-show="showLog">
<p>Log Information:</p>
<p ng-repeat="l in queryResult.getLog()">{{l}}</p>
</div>
<ul class="tab-nav">
<rd-tab ng-if="!query.visualizations.length" tab-id="table" name="Table" base-path="query.getUrl(sourceMode)"></rd-tab>
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" base-path="query.getUrl(sourceMode)" ng-repeat="vis in query.visualizations | orderBy:'id'">
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-if="canEdit && !($first && (vis.type === 'TABLE'))"> &times;</span>
</rd-tab>
<li class="rd-tab"><a ng-click="openVisualizationEditor()" ng-if="sourceMode && canEdit">&plus; New Visualization</a></li>
</ul>
<div ng-if="!query.visualizations.length" class="query__vis m-t-15 p-b-15 scrollbox">
<filters filters="filters"></filters>
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
</div>
<div ng-if="selectedTab == vis.id" ng-repeat="vis in query.visualizations" class="query__vis m-t-15 scrollbox">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
</div>
</div>
</div>
</section>
</div> </div>
<div class="bottom-controller-container"> <div class="bottom-controller-container">
<div class="bottom-controller"> <div class="bottom-controller">
@@ -243,7 +247,7 @@
uib-dropdown-toggle aria-expanded="false"> uib-dropdown-toggle aria-expanded="false">
Download <span class="hidden-xs">Dataset </span><span class="caret"></span> Download <span class="hidden-xs">Dataset </span><span class="caret"></span>
</button> </button>
<ul class="dropdown-menu pull-right" uib-dropdown-menu> <ul class="dropdown-menu" ng-class="{'pull-right': !query.isNew()}" uib-dropdown-menu>
<li> <li>
<a query-result-link target="_self"> <a query-result-link target="_self">
<span class="fa fa-file-o"></span> Download as CSV File <span class="fa fa-file-o"></span> Download as CSV File

View File

@@ -71,14 +71,6 @@ function QuerySourceCtrl(
.catch(error => toastr.error(error)); .catch(error => toastr.error(error));
}; };
$scope.duplicateQuery = () => {
Events.record('fork', 'query', $scope.query.id);
Query.fork({ id: $scope.query.id }, (newQuery) => {
$location.url(newQuery.getSourceLink()).replace();
});
};
$scope.deleteVisualization = ($e, vis) => { $scope.deleteVisualization = ($e, vis) => {
$e.preventDefault(); $e.preventDefault();

View File

@@ -172,6 +172,14 @@ function QueryViewCtrl(
}); });
}; };
$scope.duplicateQuery = () => {
Events.record('fork', 'query', $scope.query.id);
Query.fork({ id: $scope.query.id }, (newQuery) => {
$location.url(newQuery.getSourceLink()).replace();
});
};
$scope.saveQuery = (customOptions, data) => { $scope.saveQuery = (customOptions, data) => {
let request = data; let request = data;

View File

@@ -16,6 +16,7 @@ export default function init(ngModule) {
permission: 'create_query', permission: 'create_query',
title: 'Query Snippets', title: 'Query Snippets',
path: 'query_snippets', path: 'query_snippets',
order: 5,
}); });
ngModule.component('snippetsListPage', { ngModule.component('snippetsListPage', {

View File

@@ -16,38 +16,64 @@
<h3>Authentication</h3> <h3>Authentication</h3>
<p> <p>
<label> <label>
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_password_login_enabled" <input name="input" type="checkbox" ng-model="$ctrl.settings.auth_password_login_enabled" ng-change="$ctrl.update('auth_password_login_enabled')"
ng-change="$ctrl.update('auth_password_login_enabled')" accesskey="tab"> accesskey="tab" ng-disabled="$ctrl.disablePasswordLoginToggle()"> Password Login Enabled
Password Login Enabled <span uib-popover="Password login can be disabled only if another login method is enabled." popover-trigger="'mouseenter'"
ng-if="$ctrl.disablePasswordLoginToggle()">
<i class="fa fa-question-circle"></i>
</span>
</label> </label>
<div class="callout callout-warning" ng-if="!$ctrl.settings.auth_password_login_enabled">
Password based login is currently disabled and users will be able to login only with the enabled SSO options.
</div>
</p> </p>
<div ng-if="$ctrl.googleLoginEnabled">
<h4>Google Login</h4>
<label>
Allowed Google Apps Domains
</label>
<ui-select multiple tagging tagging-label="false" ng-model="$ctrl.settings.auth_google_apps_domains" tagging-tokens="SPACE|,"
title="Google Apps Domain(s)"
ng-change="$ctrl.update('auth_google_apps_domains')">
<ui-select-match placeholder="Google Apps Domain(s)">{{$item}}</ui-select-match>
<!-- the ui-select-choices is here just to make ui-select work -->
<ui-select-choices repeat="domain in $ctrl.domains">
{{domain}}
</ui-select-choices>
</ui-select>
<div class="callout callout-info m-t-5" ng-if="$ctrl.settings.auth_google_apps_domains | notEmpty">
Any user registered with a <strong>{{$ctrl.settings.auth_google_apps_domains | join}}</strong> Google Apps account will be able to login. If they don't have an existing user, a new user will be created and join the <strong>Default</strong> group.
</div>
</div>
<h4>SAML</h4> <h4>SAML</h4>
<p> <p>
<label> <label>
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_saml_enabled" <input name="input" type="checkbox" ng-model="$ctrl.settings.auth_saml_enabled" ng-change="$ctrl.update('auth_saml_enabled')"
ng-change="$ctrl.update('auth_saml_enabled')" accesskey="tab"> accesskey="tab"> SAML Enabled
SAML Enabled
</label> </label>
<div ng-show="$ctrl.settings.auth_saml_enabled"> <div ng-show="$ctrl.settings.auth_saml_enabled">
<div class="form-group"> <div class="form-group">
<label>SAML Metadata URL</label> <label>SAML Metadata URL</label>
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_metadata_url" accesskey="tab" <input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_metadata_url" accesskey="tab" ng-change="$ctrl.update('auth_saml_metadata_url')"
ng-change="$ctrl.update('auth_saml_metadata_url')" ng-model-options="{ debounce: 200 }"> ng-model-options="{ debounce: 200 }">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>SAML Entity ID</label> <label>SAML Entity ID</label>
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_entity_id" accesskey="tab" <input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_entity_id" accesskey="tab" ng-change="$ctrl.update('auth_saml_entity_id')"
ng-change="$ctrl.update('auth_saml_entity_id')" ng-model-options="{ debounce: 200 }"> ng-model-options="{ debounce: 200 }">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>SAML NameID Format</label> <label>SAML NameID Format</label>
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_nameid_format" accesskey="tab" <input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_nameid_format" accesskey="tab"
ng-change="$ctrl.update('auth_saml_nameid_format')" ng-model-options="{ debounce: 200 }"> ng-change="$ctrl.update('auth_saml_nameid_format')" ng-model-options="{ debounce: 200 }">
</div> </div>
</div> </div>
</p> </p>
</div> </div>
</div> </div>
</settings-screen> </settings-screen>

View File

@@ -1,7 +1,7 @@
import settingsMenu from '@/lib/settings-menu'; import settingsMenu from '@/lib/settings-menu';
import template from './organization.html'; import template from './organization.html';
function OrganizationSettingsCtrl($http, toastr, Events) { function OrganizationSettingsCtrl($http, toastr, clientConfig, Events) {
Events.record('view', 'page', 'org_settings'); Events.record('view', 'page', 'org_settings');
this.settings = {}; this.settings = {};
@@ -13,10 +13,20 @@ function OrganizationSettingsCtrl($http, toastr, Events) {
$http.post('api/settings/organization', { [key]: this.settings[key] }).then((response) => { $http.post('api/settings/organization', { [key]: this.settings[key] }).then((response) => {
this.settings = response.data.settings; this.settings = response.data.settings;
toastr.success('Settings changes saved.'); toastr.success('Settings changes saved.');
if (this.disablePasswordLoginToggle() && this.settings.auth_password_login_enabled === false) {
this.settings.auth_password_login_enabled = true;
this.update('auth_password_login_enabled');
}
}).catch(() => { }).catch(() => {
toastr.error('Failed saving changes.'); toastr.error('Failed saving changes.');
}); });
}; };
this.googleLoginEnabled = clientConfig.googleLoginEnabled;
this.disablePasswordLoginToggle = () =>
(clientConfig.googleLoginEnabled || this.settings.auth_saml_enabled) === false;
} }
export default function init(ngModule) { export default function init(ngModule) {
@@ -24,6 +34,7 @@ export default function init(ngModule) {
permission: 'admin', permission: 'admin',
title: 'Settings', title: 'Settings',
path: 'settings/organization', path: 'settings/organization',
order: 6,
}); });
ngModule.component('organizationSettingsPage', { ngModule.component('organizationSettingsPage', {

View File

@@ -26,7 +26,7 @@
<div ng-if="user.created" class="alert alert-success alert-invited"> <div ng-if="user.created" class="alert alert-success alert-invited">
<h4>The user has been created and should receive an invite email soon</h4> <h4>The user has been created and should receive an invite email soon</h4>
<p>You can use the following link to invite them yourself:</p> <p>You can use the following link to invite them yourself:</p>
<textarea class="form-control" rows="2" disabled>{{user.invite_link}}</textarea> <textarea class="form-control m-t-10" rows="2" readonly>{{ inviteLink }}</textarea>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,8 +1,11 @@
import { absoluteUrl } from '@/services/utils';
import template from './new.html'; import template from './new.html';
function NewUserCtrl($scope, $location, toastr, currentUser, Events, User) { function NewUserCtrl($scope, toastr, currentUser, Events, User) {
Events.record('view', 'page', 'users/new'); Events.record('view', 'page', 'users/new');
$scope.inviteLink = '';
$scope.user = new User({}); $scope.user = new User({});
$scope.saveUser = () => { $scope.saveUser = () => {
if (!this.userForm.$valid) { if (!this.userForm.$valid) {
@@ -12,6 +15,7 @@ function NewUserCtrl($scope, $location, toastr, currentUser, Events, User) {
$scope.user.$save((user) => { $scope.user.$save((user) => {
$scope.user = user; $scope.user = user;
$scope.user.created = true; $scope.user.created = true;
$scope.inviteLink = absoluteUrl(user.invite_link);
toastr.success('Saved.'); toastr.success('Saved.');
}, (error) => { }, (error) => {
const message = error.data.message || 'Failed saving.'; const message = error.data.message || 'Failed saving.';

View File

@@ -77,12 +77,12 @@
<div ng-if="passwordResetLink" class="alert alert-success"> <div ng-if="passwordResetLink" class="alert alert-success">
<p ng-if="!clientConfig.mailSettingMissing"> <p ng-if="!clientConfig.mailSettingMissing">
<strong>The user should receive a link to reset his password by email soon.</strong> <strong>The user should receive a link to reset their password by email soon.</strong>
</p> </p>
<p ng-if="clientConfig.mailSettingsMissing"> <p ng-if="clientConfig.mailSettingsMissing">
You don't have mail server configured, please send the following link You don't have mail server configured, please send the following link
to {{user.name}} to reset their password:<br/> to {{user.name}} to reset their password:<br/>
<a ng-href="passwordResetLink">{{passwordResetLink}}</a> <textarea class="form-control m-t-10" rows="2" readonly>{{ passwordResetLink }}</textarea>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { each } from 'underscore'; import { each } from 'underscore';
import settingsMenu from '@/lib/settings-menu'; import settingsMenu from '@/lib/settings-menu';
import { absoluteUrl } from '@/services/utils';
import template from './show.html'; import template from './show.html';
import './settings.less'; import './settings.less';
@@ -104,7 +105,7 @@ function UserCtrl(
$scope.disablePasswordResetButton = true; $scope.disablePasswordResetButton = true;
$http.post(`api/users/${$scope.user.id}/reset_password`).success((data) => { $http.post(`api/users/${$scope.user.id}/reset_password`).success((data) => {
$scope.disablePasswordResetButton = false; $scope.disablePasswordResetButton = false;
$scope.passwordResetLink = data.reset_link; $scope.passwordResetLink = absoluteUrl(data.reset_link);
}); });
}; };
} }
@@ -113,6 +114,7 @@ export default function init(ngModule) {
settingsMenu.add({ settingsMenu.add({
title: 'Account', title: 'Account',
path: 'users/me', path: 'users/me',
order: 7,
}); });
ngModule.controller('UserCtrl', UserCtrl); ngModule.controller('UserCtrl', UserCtrl);

View File

@@ -1,8 +1,52 @@
import * as _ from 'underscore'; import _ from 'underscore';
function prepareWidgetsForDashboard(widgets) {
// Default height for auto-height widgets.
// Compute biggest widget size and choose between it and some magic number.
// This value should be big enough so auto-height widgets will not overlap other ones.
const defaultWidgetSizeY = Math.max(
_.chain(widgets)
.map(w => w.options.position.sizeY)
.max()
.value(),
20,
) + 5;
// Fix layout:
// 1. sort and group widgets by row
// 2. update position of widgets in each row - place it right below
// biggest widget from previous row
_.chain(widgets)
.sortBy(widget => widget.options.position.row)
.groupBy(widget => widget.options.position.row)
.reduce((row, widgetsAtRow) => {
let height = 1;
_.each(widgetsAtRow, (widget) => {
height = Math.max(
height,
widget.options.position.autoHeight
? defaultWidgetSizeY
: widget.options.position.sizeY,
);
widget.options.position.row = row;
if (widget.options.position.sizeY < 1) {
widget.options.position.sizeY = defaultWidgetSizeY;
}
});
return row + height;
}, 0)
.value();
// Sort widgets by updated column and row value
widgets = _.sortBy(widgets, widget => widget.options.position.col);
widgets = _.sortBy(widgets, widget => widget.options.position.row);
return widgets;
}
function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) { function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) {
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return widgets.map(widget => new Widget(widget)); return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
} }
function transformSingle(dashboard) { function transformSingle(dashboard) {
@@ -84,6 +128,7 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions)
}; };
resource.prepareDashboardWidgets = prepareDashboardWidgets; resource.prepareDashboardWidgets = prepareDashboardWidgets;
resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
return resource; return resource;
} }

View File

@@ -31,8 +31,7 @@ function getColumnNameWithoutType(column) {
} }
export function getColumnCleanName(column) { export function getColumnCleanName(column) {
const name = getColumnNameWithoutType(column); return getColumnNameWithoutType(column);
return name;
} }
function getColumnFriendlyName(column) { function getColumnFriendlyName(column) {
@@ -120,6 +119,7 @@ function QueryResultService($resource, $timeout, $q) {
}); });
each(this.query_result.data.columns, (column) => { each(this.query_result.data.columns, (column) => {
column.name = '' + column.name;
if (columnTypes[column.name]) { if (columnTypes[column.name]) {
if (column.type == null || column.type === 'string') { if (column.type == null || column.type === 'string') {
column.type = columnTypes[column.name]; column.type = columnTypes[column.name];
@@ -183,9 +183,7 @@ function QueryResultService($resource, $timeout, $q) {
return null; return null;
} }
const data = this.query_result.data.rows; return this.query_result.data.rows;
return data;
} }
getData() { getData() {
@@ -262,12 +260,11 @@ function QueryResultService($resource, $timeout, $q) {
let sizeValue = null; let sizeValue = null;
each(row, (v, definition) => { each(row, (v, definition) => {
const name = definition.split('::')[0] || definition.split('__')[0]; definition = '' + definition;
const definitionParts = definition.split('::') || definition.split('__');
const name = definitionParts[0];
const type = mapping ? mapping[definition] : definitionParts[1];
let value = v; let value = v;
let type = definition.split('::')[1] || definition.split('__')[1];
if (mapping) {
type = mapping[definition];
}
if (type === 'unused') { if (type === 'unused') {
return; return;

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
export function absoluteUrl(url) {
const urlObj = new URL(url, window.location);
urlObj.protocol = window.location.protocol;
urlObj.host = window.location.host;
return urlObj.toString();
}

View File

@@ -1,21 +1,25 @@
import { truncate } from 'underscore.string'; import { truncate } from 'underscore.string';
import { pick, omit, flatten, extend, isObject } from 'underscore'; import { pick, flatten, extend, isObject } from 'underscore';
function Widget($resource, $http, Query, Visualization, dashboardGridOptions) { function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
function prepareForSave(data) { function prepareForSave(data) {
return omit(data, 'query'); return pick(data, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
} }
const WidgetResource = $resource('api/widgets/:id', { id: '@id' }, { const WidgetResource = $resource(
get: { method: 'GET' }, 'api/widgets/:id',
save: { { id: '@id' },
method: 'POST', {
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]), get: { method: 'GET' },
save: {
method: 'POST',
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]),
},
query: { method: 'GET', isArray: true },
remove: { method: 'DELETE' },
delete: { method: 'DELETE' },
}, },
query: { method: 'GET', isArray: true }, );
remove: { method: 'DELETE' },
delete: { method: 'DELETE' },
});
WidgetResource.prototype.getQuery = function getQuery() { WidgetResource.prototype.getQuery = function getQuery() {
if (!this.query && this.visualization) { if (!this.query && this.visualization) {
@@ -26,6 +30,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
}; };
WidgetResource.prototype.getQueryResult = function getQueryResult(force, maxAge) { WidgetResource.prototype.getQueryResult = function getQueryResult(force, maxAge) {
return this.load(force, maxAge);
};
WidgetResource.prototype.load = function load(force, maxAge) {
if (!this.visualization) { if (!this.visualization) {
return undefined; return undefined;
} }
@@ -40,6 +48,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
return this.queryResult; return this.queryResult;
}; };
WidgetResource.prototype.loadPromise = function loadPromise(force, maxAge) {
return this.load(force, maxAge).toPromise();
};
WidgetResource.prototype.getName = function getName() { WidgetResource.prototype.getName = function getName() {
if (this.visualization) { if (this.visualization) {
return `${this.visualization.query.name} (${this.visualization.name})`; return `${this.visualization.query.name} (${this.visualization.name})`;
@@ -51,25 +63,29 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
widget.width = 1; // Backward compatibility, user on back-end widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = { const visualizationOptions = {
autoHeight: false,
sizeX: Math.round(dashboardGridOptions.columns / 2), sizeX: Math.round(dashboardGridOptions.columns / 2),
sizeY: -1, // auto-height sizeY: dashboardGridOptions.defaultSizeY,
minSizeX: dashboardGridOptions.minSizeX, minSizeX: dashboardGridOptions.minSizeX,
maxSizeX: dashboardGridOptions.maxSizeX, maxSizeX: dashboardGridOptions.maxSizeX,
minSizeY: dashboardGridOptions.minSizeY, minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY, maxSizeY: dashboardGridOptions.maxSizeY,
}; };
const visualization = widget.visualization ? const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
Visualization.visualizations[widget.visualization.type] : null;
if (isObject(visualization)) { if (isObject(visualization)) {
const options = extend({}, visualization.defaultOptions); const options = extend({}, visualization.defaultOptions);
if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) {
visualizationOptions.autoHeight = options.autoHeight;
}
// Width constraints // Width constraints
const minColumns = parseInt(options.minColumns, 10); const minColumns = parseInt(options.minColumns, 10);
if (isFinite(minColumns) && (minColumns >= 0)) { if (isFinite(minColumns) && minColumns >= 0) {
visualizationOptions.minSizeX = minColumns; visualizationOptions.minSizeX = minColumns;
} }
const maxColumns = parseInt(options.maxColumns, 10); const maxColumns = parseInt(options.maxColumns, 10);
if (isFinite(maxColumns) && (maxColumns >= 0)) { if (isFinite(maxColumns) && maxColumns >= 0) {
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns); visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
} }
@@ -84,17 +100,17 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
visualizationOptions.minSizeY = minRows; visualizationOptions.minSizeY = minRows;
} }
const maxRows = parseInt(options.maxRows, 10); const maxRows = parseInt(options.maxRows, 10);
if (isFinite(maxRows) && (maxRows >= 0)) { if (isFinite(maxRows) && maxRows >= 0) {
visualizationOptions.maxSizeY = maxRows; visualizationOptions.maxSizeY = maxRows;
} }
// Default dimensions // Default dimensions
const defaultWidth = parseInt(options.defaultColumns, 10); const defaultWidth = parseInt(options.defaultColumns, 10);
if (isFinite(defaultWidth) && (defaultWidth > 0)) { if (isFinite(defaultWidth) && defaultWidth > 0) {
visualizationOptions.sizeX = defaultWidth; visualizationOptions.sizeX = defaultWidth;
} }
const defaultHeight = parseInt(options.defaultRows, 10); const defaultHeight = parseInt(options.defaultRows, 10);
if (isFinite(defaultHeight) && (defaultHeight > 0)) { if (isFinite(defaultHeight) && defaultHeight > 0) {
visualizationOptions.sizeY = defaultHeight; visualizationOptions.sizeY = defaultHeight;
} }
} }
@@ -103,16 +119,24 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
widget.options.position = extend( widget.options.position = extend(
{}, {},
visualizationOptions, visualizationOptions,
pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY']), pick(widget.options.position, ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']),
); );
return new WidgetResource(widget); if (widget.options.position.sizeY < 0) {
widget.options.position.autoHeight = true;
}
const result = new WidgetResource(widget);
// Save original position (create a shallow copy)
result.$originalPosition = extend({}, result.options.position);
return result;
} }
return WidgetConstructor; return WidgetConstructor;
} }
export default function init(ngModule) { export default function init(ngModule) {
ngModule.factory('Widget', Widget); ngModule.factory('Widget', Widget);
} }

View File

@@ -176,9 +176,15 @@ export default function init(ngModule) {
const editTemplate = '<boxplot-editor></boxplot-editor>'; const editTemplate = '<boxplot-editor></boxplot-editor>';
const defaultOptions = {
defaultRows: 8,
minRows: 5,
};
VisualizationProvider.registerVisualization({ VisualizationProvider.registerVisualization({
type: 'BOXPLOT', type: 'BOXPLOT',
name: 'Boxplot (Deprecated)', name: 'Boxplot (Deprecated)',
defaultOptions,
renderTemplate, renderTemplate,
editorTemplate: editTemplate, editorTemplate: editTemplate,
}); });

View File

@@ -85,7 +85,6 @@ function ChartEditor(ColorPalette, clientConfig) {
scope.options.seriesOptions[key].type = scope.options.globalSeriesType; scope.options.seriesOptions[key].type = scope.options.globalSeriesType;
}); });
}; };
scope.chartTypeChanged();
scope.showSizeColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'bubble'); scope.showSizeColumnPicker = () => some(scope.options.seriesOptions, options => options.type === 'bubble');

View File

@@ -78,7 +78,7 @@ const PlotlyChart = () => ({
} }
}, true); }, true);
scope.handleResize = debounce(updateChartDimensions, 100); scope.handleResize = debounce(updateChartDimensions, 50);
}, },
}); });

View File

@@ -3,37 +3,37 @@ import {
each, values, sortBy, pluck, identity, filter, map, each, values, sortBy, pluck, identity, filter, map,
} from 'underscore'; } from 'underscore';
import moment from 'moment'; import moment from 'moment';
import createFormatter from '@/lib/value-format'; import { createFormatter } from '@/lib/value-format';
// The following colors will be used if you pick "Automatic" color. // The following colors will be used if you pick "Automatic" color.
const BaseColors = { const BaseColors = {
Blue: '#4572A7', Blue: '#356AFF',
Red: '#AA4643', Red: '#E92828',
Green: '#89A54E', Green: '#3BD973',
Purple: '#80699B', Purple: '#604FE9',
Cyan: '#3D96AE', Cyan: '#50F5ED',
Orange: '#DB843D', Orange: '#FB8D3D',
'Light Blue': '#92A8CD', 'Light Blue': '#799CFF',
Lilac: '#A47D7C', Lilac: '#B554FF',
'Light Green': '#B5CA92', 'Light Green': '#8CFFB4',
Brown: '#A52A2A', Brown: '#A55F2A',
Black: '#000000', Black: '#000000',
Gray: '#808080', Gray: '#494949',
Pink: '#FFC0CB', Pink: '#FF7DE3',
'Dark Blue': '#00008b', 'Dark Blue': '#002FB4',
}; };
// Additional colors for the user to choose from: // Additional colors for the user to choose from:
export const ColorPalette = Object.assign({}, BaseColors, { export const ColorPalette = Object.assign({}, BaseColors, {
'Indian Red': '#F8766D', 'Indian Red': '#981717',
'Green 2': '#53B400', 'Green 2': '#17BF51',
'Green 3': '#00C094', 'Green 3': '#049235',
DarkTurquoise: '#00B6EB', DarkTurquoise: '#00B6EB',
'Dark Violet': '#A58AFF', 'Dark Violet': '#A58AFF',
'Pink 2': '#FB61D7', 'Pink 2': '#C63FA9',
}); });
const formatNumber = createFormatter({ displayAs: 'number', numberFormat: '0,0[.]00' }); const formatNumber = createFormatter({ displayAs: 'number', numberFormat: '0,0[.]00000' });
const formatPercent = createFormatter({ displayAs: 'number', numberFormat: '0[.]00' }); const formatPercent = createFormatter({ displayAs: 'number', numberFormat: '0[.]00' });
const ColorPaletteArray = values(BaseColors); const ColorPaletteArray = values(BaseColors);
@@ -148,8 +148,10 @@ function calculateDimensions(series, options) {
const hasX = contains(values(options.columnMapping), 'x'); const hasX = contains(values(options.columnMapping), 'x');
const hasY2 = !!find(series, (serie) => { const hasY2 = !!find(series, (serie) => {
const serieOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType }; const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
return (serieOptions.yAxis === 1) && (options.series.stacking === null); return (seriesOptions.yAxis === 1) && (
(options.series.stacking === null) || (seriesOptions.type === 'line')
);
}); });
return { return {
@@ -191,7 +193,7 @@ function preparePieData(seriesList, options) {
marker: { colors: ColorPaletteArray }, marker: { colors: ColorPaletteArray },
text: serie.name, text: serie.name,
textposition: 'inside', textposition: 'inside',
textfont: { color: '#f5f5f5' }, textfont: { color: '#ffffff' },
name: serie.name, name: serie.name,
domain: { domain: {
x: [xPosition, xPosition + cellWidth - xPadding], x: [xPosition, xPosition + cellWidth - xPadding],
@@ -249,7 +251,10 @@ function prepareChartData(seriesList, options) {
sourceData, sourceData,
}; };
if ((seriesOptions.yAxis === 1) && (options.series.stacking === null)) { if (
(seriesOptions.yAxis === 1) &&
((options.series.stacking === null) || (seriesOptions.type === 'line'))
) {
plotlySeries.yaxis = 'y2'; plotlySeries.yaxis = 'y2';
} }
@@ -261,6 +266,7 @@ function prepareChartData(seriesList, options) {
}; };
} else if (seriesOptions.type === 'box') { } else if (seriesOptions.type === 'box') {
plotlySeries.boxpoints = 'outliers'; plotlySeries.boxpoints = 'outliers';
plotlySeries.hoverinfo = false;
plotlySeries.marker = { plotlySeries.marker = {
color: seriesColor, color: seriesColor,
size: 3, size: 3,
@@ -326,6 +332,10 @@ export function prepareLayout(element, seriesList, options, data) {
type: getScaleType(options.xAxis.type), type: getScaleType(options.xAxis.type),
}; };
if (options.sortX && result.xaxis.type === 'category') {
result.xaxis.categoryorder = 'category ascending';
}
if (!isUndefined(options.xAxis.labels)) { if (!isUndefined(options.xAxis.labels)) {
result.xaxis.showticklabels = options.xAxis.labels.enabled; result.xaxis.showticklabels = options.xAxis.labels.enabled;
} }
@@ -373,14 +383,18 @@ export function prepareLayout(element, seriesList, options, data) {
function updateSeriesText(seriesList, options) { function updateSeriesText(seriesList, options) {
each(seriesList, (series) => { each(seriesList, (series) => {
series.text = []; series.text = [];
series.sourceData.forEach((item) => { series.x.forEach((x) => {
let text = formatNumber(item.y); let text = null;
if (item.yError !== undefined) { const item = series.sourceData.get(x);
text = `${text} \u00B1 ${formatNumber(item.yError)}`; if (item) {
} text = formatNumber(item.y);
if (item.yError !== undefined) {
text = `${text} \u00B1 ${formatNumber(item.yError)}`;
}
if (options.series.percentValues) { if (options.series.percentValues) {
text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`; text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
}
} }
series.text.push(text); series.text.push(text);

View File

@@ -0,0 +1,251 @@
<div>
<ul class="tab-nav">
<li ng-class="{active: currentTab == 'general'}">
<a ng-click="changeTab('general')">General</a>
</li>
<li ng-class="{active: currentTab == 'colors'}">
<a ng-click="changeTab('colors')">Colors</a>
</li>
<li ng-class="{active: currentTab == 'bounds'}">
<a ng-click="changeTab('bounds')">Bounds</a>
</li>
</ul>
<div ng-if="currentTab == 'general'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Country code column</label>
<select ng-options="name for name in queryResult.getColumnNames()"
ng-model="options.countryCodeColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Country code type</label>
<select ng-options="key as value for (key, value) in countryCodeTypes"
ng-model="options.countryCodeType" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Value column</label>
<select ng-options="name for name in queryResult.getColumnNames()"
ng-model="options.valueColumn" class="form-control"></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-format">
Value format
<span class="m-l-5"
uib-popover-html="'Format <a href=&quot;http://numeraljs.com/&quot; target=&quot;_blank&quot;>specs.</a>'"
popover-trigger="'click outsideClick'"><i class="fa fa-question-circle"></i></span>
</label>
<input class="form-control" id="legend-value-format"
ng-model="options.valueFormat" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-value-placeholder">Value placeholder</label>
<input class="form-control" id="legend-value-placeholder"
ng-model="options.noValuePlaceholder" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
<div class="form-group">
<label><input type="checkbox" ng-model="options.legend.visible"> Show legend</label>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label for="legend-position">Legend position</label>
<select class="form-control" id="legend-position"
ng-options="key as value for (key, value) in legendPositions"
ng-model="options.legend.position"
ng-disabled="!options.legend.visible"
></select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label for="legend-position">Legend text alignment</label>
<div class="btn-group d-flex">
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="options.legend.alignText = 'left'"
ng-class="{active: options.legend.alignText == 'left'}"><i class="fa fa-align-left"></i></button>
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="options.legend.alignText = 'center'"
ng-class="{active: options.legend.alignText == 'center'}"><i class="fa fa-align-center"></i></button>
<button type="button" class="btn btn-default btn-md flex-fill"
ng-click="options.legend.alignText = 'right'"
ng-class="{active: options.legend.alignText == 'right'}"><i class="fa fa-align-right"></i></button>
</div>
</div>
</div>
</div>
<label><input type="checkbox" ng-model="options.tooltip.enabled"> Show tooltip</label>
<div class="form-group">
<label for="tooltip-template">Tooltip template</label>
<input class="form-control" id="tooltip-template"
ng-model="options.tooltip.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!options.tooltip.enabled">
</div>
<label><input type="checkbox" ng-model="options.popup.enabled"> Show popup</label>
<div class="form-group">
<label for="popup-template">Popup template</label>
<textarea class="form-control resize-vertical" id="popup-template" rows="3"
ng-model="options.popup.template" ng-model-options="{ allowInvalid: true, debounce: 200 }"
ng-disabled="!options.popup.enabled"></textarea>
</div>
<div class="form-group">
<label class="ui-sortable-bypass text-muted" style="font-weight: normal; cursor: pointer;"
uib-popover-html="templateHint"
popover-trigger="'click outsideClick'" popover-placement="top-left">
Format specs <i class="fa fa-question-circle m-l-5"></i>
</label>
</div>
</div>
<div ng-if="currentTab == 'colors'" class="m-t-10 m-b-10">
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Steps</label>
<input type="number" min="3" max="11" class="form-control"
ng-model="options.steps">
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Clustering mode</label>
<select ng-options="key as value for (key, value) in clusteringModes"
ng-model="options.clusteringMode" class="form-control"></select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Min color</label>
<ui-select ng-model="options.colors.min">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Max color</label>
<ui-select ng-model="options.colors.max">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>No value color</label>
<ui-select ng-model="options.colors.noValue">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div class="form-group">
<label>Background color</label>
<ui-select ng-model="options.colors.background">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-xs-6">
<div class="form-group">
<label>Borders color</label>
<ui-select ng-model="options.colors.borders">
<ui-select-match>
<color-box color="$select.selected.value"></color-box>
<span ng-bind-html="$select.selected.key | capitalize"></span>
</ui-select-match>
<ui-select-choices repeat="color.value as (key, color) in colors">
<color-box color="color.value"></color-box>
<span ng-bind-html="color.key | capitalize | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
</div>
<div ng-if="currentTab == 'bounds'" class="m-t-10 m-b-10">
<div class="form-group">
<label>North-East latitude and longitude</label>
<div class="row">
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="options.bounds[1][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="options.bounds[1][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
<div class="form-group">
<label>South-West latitude and longitude</label>
<div class="row">
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="options.bounds[0][0]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
<div class="col-xs-6">
<input class="form-control" type="text"
ng-model="options.bounds[0][1]" ng-model-options="{ allowInvalid: true, debounce: 200 }">
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<div class="map-visualization-container">
<div resize-event="handleResize()" ng-style="{ background: options.colors.background }"></div>
<div ng-if="options.legend.visible && (legendItems.length > 0)"
class="leaflet-bar map-custom-control" ng-class="options.legend.position"
>
<div ng-repeat="item in legendItems" class="d-flex align-items-center">
<color-box color="item.color" class="m-0" style="line-height: 1px"></color-box>
<div class="flex-fill text-{{ options.legend.alignText }}">{{ formatValue(item.limit) }}</div>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,311 @@
import _ from 'underscore';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { formatSimpleTemplate } from '@/lib/value-format';
import 'leaflet-fullscreen';
import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
import {
AdditionalColors,
darkenColor,
createNumberFormatter,
prepareData,
getValueForFeature,
createScale,
prepareFeatureProperties,
getColorByValue,
inferCountryCodeType,
} from './utils';
import template from './choropleth.html';
import editorTemplate from './choropleth-editor.html';
import countriesDataUrl from './countries.geo.json';
const loadCountriesData = _.bind(function loadCountriesData($http, url) {
if (!this[url]) {
this[url] = $http.get(url).then(response => response.data);
}
return this[url];
}, {});
function choroplethRenderer($sanitize, $http) {
return {
restrict: 'E',
template,
scope: {
queryResult: '=',
options: '=?',
},
link($scope, $element) {
let countriesData = null;
let map = null;
let choropleth = null;
let updateBoundsLock = false;
function getBounds() {
if (!updateBoundsLock) {
const bounds = map.getBounds();
$scope.options.bounds = [
[bounds._southWest.lat, bounds._southWest.lng],
[bounds._northEast.lat, bounds._northEast.lng],
];
$scope.$applyAsync();
}
}
function setBounds({ disableAnimation = false } = {}) {
if (map && choropleth) {
const bounds = $scope.options.bounds || choropleth.getBounds();
const options = disableAnimation ? {
animate: false,
duration: 0,
} : null;
map.fitBounds(bounds, options);
}
}
function render() {
if (map) {
map.remove();
map = null;
choropleth = null;
}
if (!countriesData) {
return;
}
$scope.formatValue = createNumberFormatter(
$scope.options.valueFormat,
$scope.options.noValuePlaceholder,
);
const data = prepareData(
$scope.queryResult.getData(),
$scope.options.countryCodeColumn,
$scope.options.valueColumn,
);
const { limits, colors, legend } = createScale(
countriesData.features,
data,
$scope.options,
);
// Update data for legend block
$scope.legendItems = legend;
choropleth = L.geoJson(countriesData, {
onEachFeature: (feature, layer) => {
const value = getValueForFeature(feature, data, $scope.options.countryCodeType);
const valueFormatted = $scope.formatValue(value);
const featureData = prepareFeatureProperties(
feature,
valueFormatted,
data,
$scope.options.countryCodeType,
);
const color = getColorByValue(value, limits, colors, $scope.options.colors.noValue);
layer.setStyle({
color: $scope.options.colors.borders,
weight: 1,
fillColor: color,
fillOpacity: 1,
});
if ($scope.options.tooltip.enabled) {
layer.bindTooltip($sanitize(formatSimpleTemplate(
$scope.options.tooltip.template,
featureData,
)));
}
if ($scope.options.popup.enabled) {
layer.bindPopup($sanitize(formatSimpleTemplate(
$scope.options.popup.template,
featureData,
)));
}
layer.on('mouseover', () => {
layer.setStyle({
weight: 2,
fillColor: darkenColor(color),
});
});
layer.on('mouseout', () => {
layer.setStyle({
weight: 1,
fillColor: color,
});
});
},
});
const choroplethBounds = choropleth.getBounds();
map = L.map($element[0].children[0].children[0], {
center: choroplethBounds.getCenter(),
zoom: 1,
zoomSnap: 0,
layers: [choropleth],
scrollWheelZoom: false,
maxBounds: choroplethBounds,
maxBoundsViscosity: 1,
attributionControl: false,
fullscreenControl: true,
});
map.on('focus', () => { map.on('moveend', getBounds); });
map.on('blur', () => { map.off('moveend', getBounds); });
setBounds({ disableAnimation: true });
}
loadCountriesData($http, countriesDataUrl).then((data) => {
if (_.isObject(data)) {
countriesData = data;
render();
}
});
$scope.handleResize = _.debounce(() => {
if (map) {
map.invalidateSize(false);
setBounds({ disableAnimation: true });
}
}, 50);
$scope.$watch('queryResult && queryResult.getData()', render);
$scope.$watch(() => _.omit($scope.options, 'bounds'), render, true);
$scope.$watch('options.bounds', () => {
// Prevent infinite digest loop
const savedLock = updateBoundsLock;
updateBoundsLock = true;
setBounds();
updateBoundsLock = savedLock;
}, true);
},
};
}
function choroplethEditor(ChoroplethPalette) {
return {
restrict: 'E',
template: editorTemplate,
scope: {
queryResult: '=',
options: '=?',
},
link($scope) {
$scope.currentTab = 'general';
$scope.changeTab = (tab) => {
$scope.currentTab = tab;
};
$scope.colors = ChoroplethPalette;
$scope.clusteringModes = {
q: 'quantile',
e: 'equidistant',
k: 'k-means',
};
$scope.legendPositions = {
'top-left': 'top / left',
'top-right': 'top / right',
'bottom-left': 'bottom / left',
'bottom-right': 'bottom / right',
};
$scope.countryCodeTypes = {
name: 'Short name',
name_long: 'Full name',
abbrev: 'Abbreviated name',
iso_a2: 'ISO code (2 letters)',
iso_a3: 'ISO code (3 letters)',
iso_n3: 'ISO code (3 digits)',
};
$scope.templateHint = `
<div class="p-b-5">All query result columns can be referenced using <code>{{ column_name }}</code> syntax.</div>
<div class="p-b-5">Use special names to access additional properties:</div>
<div><code>{{ @@value }}</code> formatted value;</div>
<div><code>{{ @@name }}</code> short country name;</div>
<div><code>{{ @@name_long }}</code> full country name;</div>
<div><code>{{ @@abbrev }}</code> abbreviated country name;</div>
<div><code>{{ @@iso_a2 }}</code> two-letter ISO country code;</div>
<div><code>{{ @@iso_a3 }}</code> three-letter ISO country code;</div>
<div><code>{{ @@iso_n3 }}</code> three-digit ISO country code.</div>
<div class="p-t-5">This syntax is applicable to tooltip and popup templates.</div>
`;
function updateCountryCodeType() {
$scope.options.countryCodeType = inferCountryCodeType(
$scope.queryResult.getData(),
$scope.options.countryCodeColumn,
) || $scope.options.countryCodeType;
}
$scope.$watch('options.countryCodeColumn', updateCountryCodeType);
$scope.$watch('queryResult.getData()', updateCountryCodeType);
},
};
}
export default function init(ngModule) {
ngModule.constant('ChoroplethPalette', {});
ngModule.directive('choroplethRenderer', choroplethRenderer);
ngModule.directive('choroplethEditor', choroplethEditor);
ngModule.config((VisualizationProvider, ColorPalette, ChoroplethPalette) => {
_.extend(ChoroplethPalette, AdditionalColors, ColorPalette);
const renderTemplate =
'<choropleth-renderer options="visualization.options" query-result="queryResult"></choropleth-renderer>';
const editTemplate = '<choropleth-editor options="visualization.options" query-result="queryResult"></choropleth-editor>';
const defaultOptions = {
defaultColumns: 3,
defaultRows: 8,
minColumns: 2,
countryCodeColumn: '',
countryCodeType: 'iso_a3',
valueColumn: '',
clusteringMode: 'e',
steps: 5,
valueFormat: '0,0.00',
noValuePlaceholder: 'N/A',
colors: {
min: ChoroplethPalette['Light Blue'],
max: ChoroplethPalette['Dark Blue'],
background: ChoroplethPalette.White,
borders: ChoroplethPalette.White,
noValue: ChoroplethPalette['Light Gray'],
},
legend: {
visible: true,
position: 'bottom-left',
alignText: 'right',
},
tooltip: {
enabled: true,
template: '<b>{{ @@name }}</b>: {{ @@value }}',
},
popup: {
enabled: true,
template: 'Country: <b>{{ @@name_long }} ({{ @@iso_a2 }})</b>\n<br>\nValue: <b>{{ @@value }}</b>',
},
};
VisualizationProvider.registerVisualization({
type: 'CHOROPLETH',
name: 'Map (Choropleth)',
renderTemplate,
editorTemplate: editTemplate,
defaultOptions,
});
});
}

View File

@@ -0,0 +1,141 @@
import chroma from 'chroma-js';
import _ from 'underscore';
import { createFormatter } from '@/lib/value-format';
export const AdditionalColors = {
White: '#ffffff',
Black: '#000000',
'Light Gray': '#dddddd',
};
export function darkenColor(color) {
return chroma(color).darken().hex();
}
export function createNumberFormatter(format, placeholder) {
const formatter = createFormatter({
displayAs: 'number',
numberFormat: format,
});
return (value) => {
if (_.isNumber(value) && isFinite(value)) {
return formatter(value);
}
return placeholder;
};
}
export function prepareData(data, countryCodeField, valueField) {
if (!countryCodeField || !valueField) {
return {};
}
const result = {};
_.each(data, (item) => {
if (item[countryCodeField]) {
const value = parseFloat(item[valueField]);
result[item[countryCodeField]] = {
code: item[countryCodeField],
value: isFinite(value) ? value : undefined,
item,
};
}
});
return result;
}
export function prepareFeatureProperties(feature, valueFormatted, data, countryCodeType) {
const result = {};
_.each(feature.properties, (value, key) => {
result['@@' + key] = value;
});
result['@@value'] = valueFormatted;
const datum = data[feature.properties[countryCodeType]] || {};
return _.extend(result, datum.item);
}
export function getValueForFeature(feature, data, countryCodeType) {
const code = feature.properties[countryCodeType];
if (_.isString(code) && _.isObject(data[code])) {
return data[code].value;
}
return undefined;
}
export function getColorByValue(value, limits, colors, defaultColor) {
if (_.isNumber(value) && isFinite(value)) {
for (let i = 0; i < limits.length; i += 1) {
if (value <= limits[i]) {
return colors[i];
}
}
}
return defaultColor;
}
export function createScale(features, data, options) {
// Calculate limits
const values = _.uniq(_.filter(
_.map(features, feature => getValueForFeature(feature, data, options.countryCodeType)),
_.isNumber,
));
if (values.length === 0) {
return {
limits: [],
colors: [],
legend: [],
};
}
const steps = Math.min(values.length, options.steps);
if (steps === 1) {
return {
limits: values,
colors: [options.colors.max],
legend: [{
color: options.colors.max,
limit: _.first(values),
}],
};
}
const limits = chroma.limits(values, options.clusteringMode, steps - 1);
// Create color buckets
const colors = chroma.scale([options.colors.min, options.colors.max])
.colors(limits.length);
// Group values for legend
const legend = _.map(colors, (color, index) => ({
color,
limit: limits[index],
})).reverse();
return { limits, colors, legend };
}
export function inferCountryCodeType(data, countryCodeField) {
const regex = {
iso_a2: /^[a-z]{2}$/i,
iso_a3: /^[a-z]{3}$/i,
iso_n3: /^[0-9]{3}$/i,
};
const result = _.chain(data)
.reduce((memo, item) => {
const value = item[countryCodeField];
if (_.isString(value)) {
_.each(regex, (r, k) => {
memo[k] += r.test(value) ? 1 : 0;
});
}
return memo;
}, {
iso_a2: 0,
iso_a3: 0,
iso_n3: 0,
})
.pairs()
.max(item => item[1])
.value();
return (result[1] / data.length) >= 0.9 ? result[0] : null;
}

View File

@@ -19,6 +19,9 @@ const DEFAULT_OPTIONS = {
stageColumn: 'day_number', stageColumn: 'day_number',
totalColumn: 'total', totalColumn: 'total',
valueColumn: 'value', valueColumn: 'value',
autoHeight: true,
defaultRows: 8,
}; };
function groupData(sortedData) { function groupData(sortedData) {
@@ -221,16 +224,13 @@ export default function init(ngModule) {
ngModule.config((VisualizationProvider) => { ngModule.config((VisualizationProvider) => {
const editTemplate = '<cohort-editor></cohort-editor>'; const editTemplate = '<cohort-editor></cohort-editor>';
const defaultOptions = {
timeInterval: 'daily',
};
VisualizationProvider.registerVisualization({ VisualizationProvider.registerVisualization({
type: 'COHORT', type: 'COHORT',
name: 'Cohort', name: 'Cohort',
renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>', renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>',
editorTemplate: editTemplate, editorTemplate: editTemplate,
defaultOptions, defaultOptions: DEFAULT_OPTIONS,
}); });
}); });
} }

View File

@@ -13,8 +13,9 @@
<label class="control-label">Visualization Type</label> <label class="control-label">Visualization Type</label>
<select required ng-model="$ctrl.visualization.type" <select required ng-model="$ctrl.visualization.type"
ng-options="type.type as type.name for type in $ctrl.visTypes" class="form-control" ng-disabled="!$ctrl.canChangeType"
ng-change="$ctrl.typeChanged('{{$ctrl.visualization.type}}')"></select> ng-options="type.type as type.name for type in $ctrl.visTypes" class="form-control"
ng-change="$ctrl.typeChanged('{{$ctrl.visualization.type}}')"></select>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -19,6 +19,9 @@ const EditVisualizationDialog = {
this.visualization = copy(this.originalVisualization); this.visualization = copy(this.originalVisualization);
this.visTypes = Visualization.visualizationTypes; this.visTypes = Visualization.visualizationTypes;
// Don't allow to change type after creating visualization
this.canChangeType = !(this.visualization && this.visualization.id);
this.newVisualization = () => this.newVisualization = () =>
({ ({
type: Visualization.defaultVisualization.type, type: Visualization.defaultVisualization.type,

View File

@@ -0,0 +1,43 @@
<div class="form-horizontal">
<div style="margin-bottom: 20px;">
This visualization constructs funnel chart. Please notice that value column only accept number for values.
</div>
<div class="form-group">
<label class="col-lg-6">Step Column Name</label>
<div class="col-lg-6">
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.stepCol.colName" class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Step Column Display Name</label>
<div class="col-lg-6">
<input type="text" ng-model="visualization.options.stepCol.displayAs" class="form-control">
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Funnel Value Column Name</label>
<div class="col-lg-6">
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.valueCol.colName" class="form-control"></select>
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Funnel Value Column Display Name</label>
<div class="col-lg-6">
<input type="text" ng-model="visualization.options.valueCol.displayAs" class="form-control">
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Auto Sort Record By Value</label>
<div class="col-lg-6">
<input type="checkbox" ng-model="visualization.options.autoSort">
</div>
</div>
<div ng-show="!visualization.options.autoSort">
<div class="form-group">
<label class="col-lg-6">Funnel Value Columns Name</label>
<div class="col-lg-6">
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="visualization.options.sortKeyCol.colName" class="form-control"></select>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,49 @@
.funnel-visualization-container {
table {
min-width: 450px;
}
.table-borderless td, .table-borderless th {
border: 0;
vertical-align: middle;
}
.step {
max-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step .step-name {
visibility: hidden;
width: inherit;
padding: 3px 5px;
background-color: white;
border: 1px solid;
border-radius: 3px;
position: absolute;
z-index: 1;
white-space: initial;
word-wrap: break-word;
}
.step:hover .step-name {
visibility: visible;
}
div.bar {
height: 30px;
}
div.bar.centered {
margin: auto;
}
.value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.container {
position: relative;
padding: 0;
text-align: center;
}
}

View File

@@ -0,0 +1,211 @@
import { debounce, sortBy, isNumber, every, difference } from 'underscore';
import d3 from 'd3';
import angular from 'angular';
import { ColorPalette, normalizeValue } from '@/visualizations/chart/plotly/utils';
import editorTemplate from './funnel-editor.html';
import './funnel.less';
function isNoneNaNNum(val) {
if (!isNumber(val) || isNaN(val)) {
return false;
}
return true;
}
function normalizePercentage(num) {
if (num < 0.01) { return '<0.01%'; }
if (num > 1000) { return '>1000%'; }
return num.toFixed(2) + '%';
}
function Funnel(scope, element) {
this.element = element;
this.watches = [];
const vis = d3.select(element);
const options = scope.visualization.options;
function drawFunnel(data) {
const maxToPrevious = d3.max(data, d => d.pctPrevious);
// Table
const table = vis.append('table')
.attr('class', 'table table-condensed table-hover table-borderless');
// Header
const header = table.append('thead').append('tr');
header.append('th').text(options.stepCol.displayAs);
header.append('th').attr('class', 'text-center').text(options.valueCol.displayAs);
header.append('th').attr('class', 'text-center').text('% Max');
header.append('th').attr('class', 'text-center').text('% Previous');
// Body
const trs = table.append('tbody')
.selectAll('tr')
.data(data)
.enter()
.append('tr');
// Steps row
trs.append('td')
.attr('class', 'col-xs-3 step')
.text(d => d.step)
.append('div')
.attr('class', 'step-name')
.text(d => d.step);
// Funnel bars
const valContainers = trs.append('td')
.attr('class', 'col-xs-5')
.append('div')
.attr('class', 'container');
valContainers.append('div')
.attr('class', 'bar centered')
.style('background-color', ColorPalette.Cyan)
.style('width', d => d.pctMax + '%');
valContainers.append('div')
.attr('class', 'value')
.text(d => d.value.toLocaleString());
// pctMax
trs.append('td')
.attr('class', 'col-xs-2 text-center')
.text(d => normalizePercentage(d.pctMax));
// pctPrevious
const pctContainers = trs.append('td')
.attr('class', 'col-xs-2')
.append('div')
.attr('class', 'container');
pctContainers.append('div')
.attr('class', 'bar')
.style('background-color', ColorPalette.Gray)
.style('opacity', '0.2')
.style('width', d => (d.pctPrevious / maxToPrevious * 100.0) + '%');
pctContainers.append('div')
.attr('class', 'value')
.text(d => normalizePercentage(d.pctPrevious));
}
function createVisualization(data) {
drawFunnel(data); // draw funnel
}
function removeVisualization() {
vis.selectAll('table').remove();
}
function prepareData(queryData) {
const data = queryData.map(row => ({
step: normalizeValue(row[options.stepCol.colName]),
value: Number(row[options.valueCol.colName]),
sortVal: options.autoSort ? '' : row[options.sortKeyCol.colName],
}), []);
let sortedData;
if (options.autoSort) {
sortedData = sortBy(data, 'value').reverse();
} else {
sortedData = sortBy(data, 'sortVal');
}
// Column validity
if (sortedData[0].value === 0 || !every(sortedData, d => isNoneNaNNum(d.value))) {
return;
}
const maxVal = d3.max(data, d => d.value);
sortedData.forEach((d, i) => {
d.pctMax = d.value / maxVal * 100.0;
d.pctPrevious = i === 0 ? 100.0 : d.value / sortedData[i - 1].value * 100.0;
});
return sortedData.slice(0, 100);
}
function invalidColNames() {
const colNames = scope.queryResult.getColumnNames();
const colToCheck = [options.stepCol.colName, options.valueCol.colName];
if (!options.autoSort) { colToCheck.push(options.sortKeyCol.colName); }
if (difference(colToCheck, colNames).length > 0) {
return true;
}
return false;
}
function refresh() {
removeVisualization();
if (invalidColNames()) { return; }
const queryData = scope.queryResult.getData();
const data = prepareData(queryData, options);
if (data) {
createVisualization(data); // draw funnel
}
}
refresh();
this.watches.push(scope.$watch('visualization.options', refresh, true));
this.watches.push(scope.$watch('queryResult && queryResult.getData()', refresh));
}
Funnel.prototype.remove = function remove() {
this.watches.forEach((unregister) => {
unregister();
});
angular.element(this.element).empty('.vis-container');
};
function funnelRenderer() {
return {
restrict: 'E',
template: '<div class="funnel-visualization-container resize-event="handleResize()"></div>',
link(scope, element) {
const container = element[0].querySelector('.funnel-visualization-container');
let funnel = new Funnel(scope, container);
function resize() {
funnel.remove();
funnel = new Funnel(scope, container);
}
scope.handleResize = debounce(resize, 50);
scope.$watch('visualization.options', (oldValue, newValue) => {
if (oldValue !== newValue) {
resize();
}
});
},
};
}
function funnelEditor() {
return {
restrict: 'E',
template: editorTemplate,
};
}
export default function init(ngModule) {
ngModule.directive('funnelRenderer', funnelRenderer);
ngModule.directive('funnelEditor', funnelEditor);
ngModule.config((VisualizationProvider) => {
const renderTemplate =
'<funnel-renderer options="visualization.options" query-result="queryResult"></funnel-renderer>';
const editTemplate = '<funnel-editor></funnel-editor>';
const defaultOptions = {
stepCol: { colName: '', displayAs: 'Steps' },
valueCol: { colName: '', displayAs: 'Value' },
sortKeyCol: { colName: '' },
autoSort: true,
defaultRows: 10,
};
VisualizationProvider.registerVisualization({
type: 'FUNNEL',
name: 'Funnel',
renderTemplate,
editorTemplate: editTemplate,
defaultOptions,
});
});
}

View File

@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png'; import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import 'leaflet-fullscreen';
import 'leaflet-fullscreen/dist/leaflet.fullscreen.css';
import template from './map.html'; import template from './map.html';
import editorTemplate from './map-editor.html'; import editorTemplate from './map-editor.html';
@@ -23,14 +25,16 @@ L.Icon.Default.mergeOptions({
delete L.Icon.Default.prototype._getIconUrl; delete L.Icon.Default.prototype._getIconUrl;
function mapRenderer() { function mapRenderer() {
return { return {
restrict: 'E', restrict: 'E',
template, template,
link($scope, elm) { link($scope, elm) {
const colorScale = d3.scale.category10(); const colorScale = d3.scale.category10();
const map = L.map(elm[0].children[0].children[0], { scrollWheelZoom: false }); const map = L.map(elm[0].children[0].children[0], {
scrollWheelZoom: false,
fullscreenControl: true,
});
const mapControls = L.control.layers().addTo(map); const mapControls = L.control.layers().addTo(map);
const layers = {}; const layers = {};
const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -209,7 +213,6 @@ function mapRenderer() {
$scope.$watch('queryResult && queryResult.getData()', render); $scope.$watch('queryResult && queryResult.getData()', render);
$scope.$watch('visualization.options', render, true); $scope.$watch('visualization.options', render, true);
$scope.$watch('visualization.options.height', resize);
}, },
}; };
} }
@@ -297,7 +300,7 @@ export default function init(ngModule) {
VisualizationProvider.registerVisualization({ VisualizationProvider.registerVisualization({
type: 'MAP', type: 'MAP',
name: 'Map', name: 'Map (Markers)',
renderTemplate, renderTemplate,
editorTemplate: editTemplate, editorTemplate: editTemplate,
defaultOptions, defaultOptions,

View File

@@ -5,7 +5,7 @@
<li ng-class="{active: currentTab == 'map'}"><a ng-click="currentTab='map'">Map Settings</a></li> <li ng-class="{active: currentTab == 'map'}"><a ng-click="currentTab='map'">Map Settings</a></li>
</ul> </ul>
<div ng-show="currentTab == 'general'"> <div ng-show="currentTab == 'general'" class="m-t-10 m-b-10">
<div class="form-group"> <div class="form-group">
<label class="control-label">Latitude Column Name</label> <label class="control-label">Latitude Column Name</label>
<ui-select name="form-control" required ng-model="visualization.options.latColName"> <ui-select name="form-control" required ng-model="visualization.options.latColName">
@@ -40,7 +40,7 @@
</div> </div>
</div> </div>
<div ng-show="currentTab == 'groups'"> <div ng-show="currentTab == 'groups'" class="m-b-10">
<table class="table table-condensed col-table"> <table class="table table-condensed col-table">
<thead> <thead>
<th>Name</th> <th>Name</th>
@@ -50,14 +50,14 @@
<tr ng-repeat="(name, options) in visualization.options.groups"> <tr ng-repeat="(name, options) in visualization.options.groups">
<td>{{name}}</td> <td>{{name}}</td>
<td> <td>
<input class="form-control" type="color" ng-model="options.color"/> <input class="form-control" type="color" ng-model="options.color"/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div ng-show="currentTab == 'map'"> <div ng-show="currentTab == 'map'" class="m-t-10 m-b-10">
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" ng-model="visualization.options.clusterMarkers"> <input type="checkbox" ng-model="visualization.options.clusterMarkers">
@@ -67,8 +67,8 @@
<div class="form-group"> <div class="form-group">
<label class="control-label">Map Tiles</label> <label class="control-label">Map Tiles</label>
<select ng-options="tile.url as tile.name for tile in mapTiles" ng-model="visualization.options.mapTileUrl" <select ng-options="tile.url as tile.name for tile in mapTiles"
class="form-control"></select> ng-model="visualization.options.mapTileUrl" class="form-control"></select>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,3 @@
<div class="map-visualization-container"> <div class="map-visualization-container">
<div resize-event="handleResize()" style="width:100%; height:100%;"></div> <div resize-event="handleResize()"></div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import _ from 'underscore'; import _ from 'underscore';
import { getColumnCleanName } from '@/services/query-result'; import { getColumnCleanName } from '@/services/query-result';
import createFormatter from '@/lib/value-format'; import { createFormatter } from '@/lib/value-format';
import template from './table.html'; import template from './table.html';
import editorTemplate from './table-editor.html'; import editorTemplate from './table-editor.html';
import './table-editor.less'; import './table-editor.less';
@@ -19,8 +19,9 @@ const DISPLAY_AS_OPTIONS = [
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
itemsPerPage: 15, itemsPerPage: 15,
autoHeight: true,
defaultRows: 14, defaultRows: 14,
defaultColumns: 4, defaultColumns: 3,
minColumns: 2, minColumns: 2,
}; };
@@ -47,7 +48,7 @@ function getDefaultColumnsOptions(columns) {
allowSearch: false, allowSearch: false,
alignContent: getColumnContentAlignment(col.type), alignContent: getColumnContentAlignment(col.type),
// `string` cell options // `string` cell options
allowHTML: false, allowHTML: true,
highlightLinks: false, highlightLinks: false,
})); }));
} }

View File

@@ -97,12 +97,17 @@ export default function init(ngModule) {
ngModule.directive('wordCloudEditor', wordCloudEditor); ngModule.directive('wordCloudEditor', wordCloudEditor);
ngModule.directive('wordCloudRenderer', wordCloudRenderer); ngModule.directive('wordCloudRenderer', wordCloudRenderer);
const defaultOptions = {
defaultRows: 8,
};
ngModule.config((VisualizationProvider) => { ngModule.config((VisualizationProvider) => {
VisualizationProvider.registerVisualization({ VisualizationProvider.registerVisualization({
type: 'WORD_CLOUD', type: 'WORD_CLOUD',
name: 'Word Cloud', name: 'Word Cloud',
renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>', renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>',
editorTemplate: '<word-cloud-editor></word-cloud-editor>', editorTemplate: '<word-cloud-editor></word-cloud-editor>',
defaultOptions,
}); });
}); });
} }

View File

@@ -33,11 +33,11 @@ services:
WORKERS_COUNT: 2 WORKERS_COUNT: 2
redis: redis:
image: redis:3.0-alpine image: redis:3.0-alpine
restart: always restart: unless-stopped
postgres: postgres:
image: postgres:9.5.6-alpine image: postgres:9.5.6-alpine
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3 # The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for # improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
# tests. # tests.
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
restart: always restart: unless-stopped

1315
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,10 @@
"scripts": { "scripts": {
"start": "webpack-dev-server", "start": "webpack-dev-server",
"dev": "REDASH_BACKEND=https://dev.redashapp.com npm start", "dev": "REDASH_BACKEND=https://dev.redashapp.com npm start",
"build": "rm -rf ./client/dist/ && NODE_ENV=production node node_modules/.bin/webpack", "build": "rm -rf ./client/dist/ && NODE_ENV=production webpack",
"watch": "webpack --watch --progress --colors -d" "watch": "webpack --watch --progress --colors -d",
"analyze": "rm -rf ./client/dist/ && BUNDLE_ANALYZER=on webpack",
"analyze:build": "rm -rf ./client/dist/ && NODE_ENV=production BUNDLE_ANALYZER=on webpack"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -26,7 +28,6 @@
"dependencies": { "dependencies": {
"angular": "~1.5.8", "angular": "~1.5.8",
"angular-base64-upload": "^0.1.23", "angular-base64-upload": "^0.1.23",
"angular-gridster": "^0.13.14",
"angular-messages": "~1.5.8", "angular-messages": "~1.5.8",
"angular-moment": "^1.1.0", "angular-moment": "^1.1.0",
"angular-resizable": "^1.2.0", "angular-resizable": "^1.2.0",
@@ -39,16 +40,19 @@
"angular-vs-repeat": "^1.1.7", "angular-vs-repeat": "^1.1.7",
"bootstrap": "^3.3.7", "bootstrap": "^3.3.7",
"brace": "^0.10.0", "brace": "^0.10.0",
"chroma-js": "^1.3.6",
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz", "core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
"cornelius": "git+https://github.com/restorando/cornelius.git", "cornelius": "git+https://github.com/restorando/cornelius.git",
"d3": "^3.5.17", "d3": "^3.5.17",
"d3-cloud": "^1.2.4", "d3-cloud": "^1.2.4",
"debug": "^3.1.0", "debug": "^3.1.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"gridstack": "^0.3.0",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"jquery-ui": "^1.12.1", "jquery-ui": "^1.12.1",
"leaflet": "^1.2.0", "leaflet": "^1.2.0",
"leaflet.markercluster": "^1.1.0", "leaflet.markercluster": "^1.1.0",
"leaflet-fullscreen": "^1.0.2",
"markdown": "0.5.0", "markdown": "0.5.0",
"material-design-iconic-font": "^2.2.0", "material-design-iconic-font": "^2.2.0",
"moment": "^2.19.3", "moment": "^2.19.3",
@@ -88,6 +92,7 @@
"url-loader": "^0.5.9", "url-loader": "^0.5.9",
"webpack": "^3.6.0", "webpack": "^3.6.0",
"webpack-build-notifier": "^0.1.16", "webpack-build-notifier": "^0.1.16",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-dev-server": "^2.9.1", "webpack-dev-server": "^2.9.1",
"webpack-manifest-plugin": "^1.3.2" "webpack-manifest-plugin": "^1.3.2"
} }

View File

@@ -19,7 +19,7 @@ from redash.query_runner import import_query_runners
from redash.destinations import import_destinations from redash.destinations import import_destinations
__version__ = '4.0.0-beta' __version__ = '4.0.0'
def setup_logging(): def setup_logging():
@@ -91,7 +91,7 @@ class SlugConverter(BaseConverter):
def create_app(load_admin=True): def create_app(load_admin=True):
from redash import handlers from redash import extensions, handlers
from redash.handlers.webpack import configure_webpack from redash.handlers.webpack import configure_webpack
from redash.admin import init_admin from redash.admin import init_admin
from redash.models import db from redash.models import db
@@ -137,5 +137,5 @@ def create_app(load_admin=True):
limiter.init_app(app) limiter.init_app(app)
handlers.init_app(app) handlers.init_app(app)
configure_webpack(app) configure_webpack(app)
extensions.init_extensions(app)
return app return app

Some files were not shown because too many files have changed in this diff Show More