Compare commits

...

80 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
76 changed files with 3185 additions and 515 deletions

View File

@@ -1,5 +1,54 @@
# 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

View File

@@ -60,7 +60,7 @@ If you would like to suggest an enhancement or ask for a new feature:
### 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

View File

@@ -23,10 +23,6 @@ server() {
}
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
}
@@ -97,3 +93,4 @@ case "$1" in
exec "$@"
;;
esac

View File

@@ -23,7 +23,7 @@ deployment:
commands:
- bin/pack
docker:
tag: [/v*/]
tag: /v[0-9]+(\.[0-9\-a-z]+)*/
commands:
- bin/pack
- 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: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

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

@@ -1,3 +1,37 @@
.map-visualization-container {
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 '~angular-toastr/src/toastr';
@import '~angular-resizable/src/angular-resizable.css';
@import '~angular-gridster/src/angular-gridster';
@import '~pace-progress/themes/blue/pace-theme-minimal.css';
@import '~material-design-iconic-font/dist/css/material-design-iconic-font.css';
@@ -51,7 +50,6 @@
@import 'inc/navbar';
@import 'inc/edit-in-place';
@import 'inc/growl';
@import 'inc/gridster';
@import 'inc/flex';
@import 'inc/ace-editor';
@import 'inc/overlay';

View File

@@ -52,9 +52,7 @@ class AppViewComponent {
// For routes that need authentication, check if session is already
// loaded, and load it if not.
logger('Requested authenticated route: ', route);
if (Auth.isAuthenticated()) {
this.applyLayout($$route);
} else {
if (!Auth.isAuthenticated()) {
event.preventDefault();
// Auth.requireSession resolves only if session loaded
Auth.requireSession().then(() => {
@@ -62,12 +60,17 @@ class AppViewComponent {
$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) => {
const $$route = current.$$route || { authenticated: true };
this.applyLayout($$route);
throw new PromiseRejectionError(rejection);
});
}

View File

@@ -4,7 +4,7 @@
</div>
<div class="modal-body">
<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 ng-show="$ctrl.widget.new_text">
<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="tile body-container" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
ng-switch="$ctrl.queryResult.getStatus()">
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
ng-switch="$ctrl.widget.getQueryResult().getStatus()">
<div class="body-row">
<div class="t-header widget clearfix">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
@@ -14,23 +14,23 @@
</div>
<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.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><a ng-href="{{$ctrl.query.getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</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.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.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>
</ul>
</div>
<div class="th-title">
<p class="hidden-print">
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.query.name}}</span>
<query-link query="$ctrl.query" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
<span ng-hide="$ctrl.canViewQuery">{{$ctrl.widget.getQuery().name}}</span>
<query-link query="$ctrl.widget.getQuery()" visualization="$ctrl.widget.visualization" ng-show="$ctrl.canViewQuery"></query-link>
<small><visualization-name visualization="$ctrl.widget.visualization"/></small>
</p>
<p class="visible-print">
{{$ctrl.query.name}}
{{$ctrl.widget.getQuery().name}}
<visualization-name visualization="$ctrl.widget.visualization"/>
</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 class="m-b-10" ng-if="$ctrl.localParametersDefs().length > 0">
@@ -39,10 +39,10 @@
</div>
<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 ng-switch-when="done" class="body-row-auto scrollbox" ng-style="$ctrl.getWidgetStyles()">
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.queryResult" class="t-body"></visualization-renderer>
<div ng-switch-when="done" class="body-row-auto scrollbox">
<visualization-renderer visualization="$ctrl.widget.visualization" query-result="$ctrl.widget.getQueryResult()" class="t-body"></visualization-renderer>
</div>
<div ng-switch-default class="body-row-auto spinner-container">
<div class="spinner">
@@ -52,20 +52,20 @@
<div class="body-row clearfix tile__bottom-control">
<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>
<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 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>
<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 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="text-center">
<h1><span class="zmdi zmdi-lock"></span></h1>
@@ -76,7 +76,7 @@
</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="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
<div class="dropdown-header">

View File

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

View File

@@ -39,12 +39,26 @@
.t-header.widget {
.dropdown {
margin-top: -5px;
margin-right: -5px;
margin-top: -15px;
margin-right: -15px;
.actions {
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>
<hr>
<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"
ng-if="field.property.type !== 'file' && field.property.type !== 'checkbox'" accesskey="tab" placeholder="{{field.property.default}}">
<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"
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>
<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,21 +1,14 @@
import { isUndefined, isFunction } from 'underscore';
const hasOwnProperty = Object.prototype.hasOwnProperty;
import { isFunction, extend } from 'underscore';
import { formatSimpleTemplate } from '@/lib/value-format';
function trim(str) {
return str.replace(/^\s+|\s+$/g, '');
}
function processTags(str, data, defaultColumn) {
return str.replace(/{{\s*([^\s]+)\s*}}/g, (match, column) => {
if (column === '@') {
column = defaultColumn;
}
if (hasOwnProperty.call(data, column) && !isUndefined(data[column])) {
return data[column];
}
return match;
});
return formatSimpleTemplate(str, extend({
'@': data[defaultColumn],
}, data));
}
export function renderDefault(column, row) {

View File

@@ -10,14 +10,18 @@
<h4>Let's get started</h4>
<ol>
<li ng-class="{done: $ctrl.dataSourceStepCompleted}">
<a href="data_sources">Connect</a> a Data Source</li>
<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}">
<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?

View File

@@ -14,8 +14,9 @@ const EmptyStateComponent = {
showInviteStep: '<',
onboardingMode: '<',
},
controller($http, $uibModal) {
controller($http, $uibModal, currentUser) {
this.loading = true;
this.isAdmin = currentUser.isAdmin;
$http.get('api/organization/status').then((response) => {
this.loading = false;

View File

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

View File

@@ -17,7 +17,6 @@ import 'angular-moment';
import 'brace';
import 'angular-ui-ace';
import 'angular-resizable';
import ngGridster from 'angular-gridster';
import { each, isFunction } from 'underscore';
import '@/lib/sortable';
@@ -52,7 +51,6 @@ const requirements = [
'angularResizable',
vsRepeat,
'ui.sortable',
ngGridster.name,
];
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 'angular-toastr/dist/angular-toastr.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 '@/assets/css/superflat_redash.css';

View File

@@ -1,78 +0,0 @@
import * as _ from 'underscore';
import { requestAnimationFrame } from './utils';
function gridsterAutoHeight($timeout, $parse) {
return {
restrict: 'A',
require: 'gridsterItem',
link($scope, $element, attr, controller) {
let autoSized = true;
const itemGetter = $parse(attr.gridsterItem);
$scope.$watch(attr.gridsterItem, (newValue, oldValue) => {
const item = _.extend({}, itemGetter($scope));
if (_.isObject(newValue) && _.isObject(oldValue)) {
if ((newValue.sizeY !== oldValue.sizeY) && !autoSized) {
item.autoHeight = false;
if (_.isFunction(itemGetter.assign)) {
itemGetter.assign($scope, item);
}
}
}
if (item.autoHeight) {
$element.addClass('gridster-auto-height-enabled');
} else {
$element.removeClass('gridster-auto-height-enabled');
}
autoSized = false;
}, true);
function updateHeight() {
const item = _.extend({}, itemGetter($scope));
if (controller.gridster && item.autoHeight) {
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(() => {
const sizeY = Math.ceil((contentsHeight + additionalHeight) /
controller.gridster.curRowHeight);
if (controller.sizeY !== sizeY) {
autoSized = true;
controller.sizeY = sizeY;
} else {
autoSized = false;
}
});
}
requestAnimationFrame(updateHeight);
}
}
updateHeight();
},
};
}
export default function init(ngModule) {
ngModule.directive('gridsterAutoHeight', gridsterAutoHeight);
}

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ import _ from 'underscore';
// eslint-disable-next-line
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) {
if (highlightLinks) {
return (value) => {
@@ -50,7 +52,7 @@ function createNumberFormatter(format) {
return value => value;
}
export default function createFormatter(column) {
export function createFormatter(column) {
switch (column.displayAs) {
case 'number': return createNumberFormatter(column.numberFormat);
case 'boolean': return createBooleanFormatter(column.booleanValues);
@@ -58,3 +60,15 @@ export default function createFormatter(column) {
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

@@ -84,12 +84,16 @@
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange(filter, $modal)"></filters>
</div>
<div style="overflow: hidden; padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper"
<div style="padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
<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}">
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position"
gridster-auto-height=".scrollbox, .spinner-container">
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" on-delete="$ctrl.removeWidget()"></dashboard-widget>
<div class="dashboard-widget-wrapper"
ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id"
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>

View File

@@ -12,20 +12,12 @@ function isWidgetPositionChanged(oldPosition, newPosition) {
return !!_.find(fields, key => newPosition[key] !== oldPosition[key]);
}
function collectWidgetPositions(widgets) {
return _.chain(widgets)
.map(widget => [widget.id, _.clone(widget.options.position)])
.object()
.value();
}
function getWidgetsWithChangedPositions(widgets, savedPositions) {
function getWidgetsWithChangedPositions(widgets) {
return _.filter(widgets, (widget) => {
const savedPosition = savedPositions[widget.id];
if (!_.isObject(savedPosition)) {
if (!_.isObject(widget.$originalPosition)) {
return true;
}
return isWidgetPositionChanged(savedPosition, widget.options.position);
return isWidgetPositionChanged(widget.$originalPosition, widget.options.position);
});
}
@@ -42,30 +34,27 @@ function DashboardCtrl(
currentUser,
clientConfig,
Events,
dashboardGridOptions,
toastr,
) {
this.saveInProgress = false;
// This variable should always be in sync with widgets
let savedWidgetPositions = {};
const saveDashboardLayout = (widgets) => {
if (!this.dashboard.canEdit()) {
return;
}
this.saveInProgress = true;
const showMessages = true; // this.layoutEditing;
// Temporarily disable grid editing (but allow user to use UI controls)
this.dashboardGridOptions.draggable.enabled = false;
this.dashboardGridOptions.resizable.enabled = false;
const showMessages = true;
return $q
.all(_.map(widgets, widget => widget.$save()))
.then(() => {
if (showMessages) {
toastr.success('Changes saved.');
}
// Update original widgets positions
_.each(widgets, (widget) => {
_.extend(widget.$originalPosition, widget.options.position);
});
})
.catch(() => {
if (showMessages) {
@@ -74,51 +63,28 @@ function DashboardCtrl(
})
.finally(() => {
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.dashboardGridOptions = _.extend({}, dashboardGridOptions, {
resizable: {
enabled: false,
handles: ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'],
},
draggable: {
enabled: false,
},
});
this.isFullscreen = false;
this.refreshRate = null;
this.isGridDisabled = false;
this.updateGridItems = null;
this.showPermissionsControl = clientConfig.showPermissionsControl;
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 =
clientConfig.dashboardRefreshIntervals.map(interval => ({ name: durationHumanize(interval), rate: interval }));
this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
name: durationHumanize(interval),
rate: interval,
}));
$rootScope.$on('gridster-mobile-changed', ($event, gridster) => {
this.isGridDisabled = gridster.isMobile;
});
this.setRefreshRate = (rate) => {
this.setRefreshRate = (rate, load = true) => {
this.refreshRate = rate;
if (rate !== null) {
this.loadDashboard(true);
if (load) {
this.loadDashboard(true);
}
this.autoRefresh();
}
};
@@ -152,8 +118,7 @@ function DashboardCtrl(
};
const collectFilters = (dashboard, forceRefresh) => {
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.getQueryResult(forceRefresh)))
.map(queryResult => queryResult.toPromise());
const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.loadPromise(forceRefresh)));
$q.all(queryResultPromises).then((queryResults) => {
const filters = {};
@@ -199,9 +164,10 @@ function DashboardCtrl(
};
this.loadDashboard = _.throttle((force) => {
this.dashboard = Dashboard.get(
Dashboard.get(
{ slug: $routeParams.dashboardSlug },
(dashboard) => {
this.dashboard = dashboard;
Events.record('view', 'dashboard', dashboard.id);
renderDashboard(dashboard, force);
@@ -210,7 +176,19 @@ function DashboardCtrl(
this.editLayout(true);
}
savedWidgetPositions = collectWidgetPositions(dashboard.widgets);
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) => {
const statusGroup = Math.floor(rejection.status / 100);
@@ -260,34 +238,25 @@ function DashboardCtrl(
this.editLayout = (enableEditing, applyChanges) => {
if (!this.isGridDisabled) {
if (enableEditing) {
if (!this.layoutEditing) {
// Save current positions of widgets
savedWidgetPositions = collectWidgetPositions(this.dashboard.widgets);
}
} else {
if (!enableEditing) {
if (applyChanges) {
const changedWidgets = getWidgetsWithChangedPositions(
this.dashboard.widgets,
savedWidgetPositions,
);
saveDashboardLayout(changedWidgets).finally(() => {
savedWidgetPositions = collectWidgetPositions(this.dashboard.widgets);
});
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
saveDashboardLayout(changedWidgets);
} else {
// Revert changes
const items = {};
_.each(this.dashboard.widgets, (widget) => {
if (_.isObject(savedWidgetPositions[widget.id])) {
widget.options.position = savedWidgetPositions[widget.id];
}
_.extend(widget.options.position, widget.$originalPosition);
items[widget.id] = widget.options.position;
});
this.dashboard.widgets = Dashboard.prepareWidgetsForDashboard(this.dashboard.widgets);
if (this.updateGridItems) {
this.updateGridItems(items);
}
}
savedWidgetPositions = collectWidgetPositions(this.dashboard.widgets);
}
this.layoutEditing = enableEditing;
this.dashboardGridOptions.draggable.enabled = this.layoutEditing && !this.saveInProgress;
this.dashboardGridOptions.resizable.enabled = this.layoutEditing && !this.saveInProgress;
}
};
@@ -330,11 +299,7 @@ function DashboardCtrl(
// Save position of newly added widget (but not entire layout)
const widget = _.last(this.dashboard.widgets);
if (_.isObject(widget)) {
return widget.$save().then(() => {
if (this.layoutEditing) {
savedWidgetPositions[widget.id] = _.clone(widget.options.position);
}
});
return widget.$save();
}
});
};
@@ -342,16 +307,10 @@ function DashboardCtrl(
this.removeWidget = () => {
this.extractGlobalParameters();
if (!this.layoutEditing) {
// We need to wait a bit for `angular-gridster` before it updates widgets,
// and only then save new layout
// We need to wait a bit while `angular` updates widgets, and only then save new layout
$timeout(() => {
const changedWidgets = getWidgetsWithChangedPositions(
this.dashboard.widgets,
savedWidgetPositions,
);
saveDashboardLayout(changedWidgets).finally(() => {
savedWidgetPositions = collectWidgetPositions(this.dashboard.widgets);
});
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
saveDashboardLayout(changedWidgets);
}, 50);
}
};

View File

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

View File

@@ -6,11 +6,14 @@
<filters ng-if="$ctrl.dashboard.dashboard_filters_enabled"></filters>
</div>
<div style="overflow: hidden; padding-bottom: 5px">
<div gridster="$ctrl.dashboardGridOptions" class="dashboard-wrapper">
<div ng-repeat="widget in $ctrl.dashboard.widgets" gridster-item="widget.options.position"
gridster-auto-height=".scrollbox, .spinner-container">
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" public="true"></dashboard-widget>
<div style="padding-bottom: 5px" ng-if="$ctrl.dashboard.widgets.length > 0">
<div gridstack editing="false" class="dashboard-wrapper preview-mode">
<div class="dashboard-widget-wrapper"
ng-repeat="widget in $ctrl.dashboard.widgets"
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>

View File

@@ -71,14 +71,6 @@ function QuerySourceCtrl(
.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) => {
$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) => {
let request = data;

View File

@@ -77,7 +77,7 @@
<div ng-if="passwordResetLink" class="alert alert-success">
<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 ng-if="clientConfig.mailSettingsMissing">
You don't have mail server configured, please send the following link

View File

@@ -1,12 +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 prepareDashboardWidgets(widgets) {
const widgetObjects = widgets.map(widget => new Widget(widget));
// This sorting is needed for converted dashboards, whose widgets don't have size yet. In such cases
// Gridster might position them wrong on the dashboard (unless sorted by col/row).
const sortedByCol = _.sortBy(widgetObjects, widget => widget.options.position && widget.options.position.col);
return _.sortBy(sortedByCol, widget => widget.options.position && widget.options.position.row * -1);
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
}
function transformSingle(dashboard) {
@@ -88,6 +128,7 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions)
};
resource.prepareDashboardWidgets = prepareDashboardWidgets;
resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
return resource;
}

View File

@@ -1,21 +1,25 @@
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 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' }, {
get: { method: 'GET' },
save: {
method: 'POST',
transformRequest: flatten([prepareForSave, $http.defaults.transformRequest]),
const WidgetResource = $resource(
'api/widgets/:id',
{ id: '@id' },
{
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() {
if (!this.query && this.visualization) {
@@ -26,6 +30,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
};
WidgetResource.prototype.getQueryResult = function getQueryResult(force, maxAge) {
return this.load(force, maxAge);
};
WidgetResource.prototype.load = function load(force, maxAge) {
if (!this.visualization) {
return undefined;
}
@@ -40,6 +48,10 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
return this.queryResult;
};
WidgetResource.prototype.loadPromise = function loadPromise(force, maxAge) {
return this.load(force, maxAge).toPromise();
};
WidgetResource.prototype.getName = function getName() {
if (this.visualization) {
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
const visualizationOptions = {
autoHeight: false,
sizeX: Math.round(dashboardGridOptions.columns / 2),
sizeY: -1, // auto-height
sizeY: dashboardGridOptions.defaultSizeY,
minSizeX: dashboardGridOptions.minSizeX,
maxSizeX: dashboardGridOptions.maxSizeX,
minSizeY: dashboardGridOptions.minSizeY,
maxSizeY: dashboardGridOptions.maxSizeY,
};
const visualization = widget.visualization ?
Visualization.visualizations[widget.visualization.type] : null;
const visualization = widget.visualization ? Visualization.visualizations[widget.visualization.type] : null;
if (isObject(visualization)) {
const options = extend({}, visualization.defaultOptions);
if (Object.prototype.hasOwnProperty.call(options, 'autoHeight')) {
visualizationOptions.autoHeight = options.autoHeight;
}
// Width constraints
const minColumns = parseInt(options.minColumns, 10);
if (isFinite(minColumns) && (minColumns >= 0)) {
if (isFinite(minColumns) && minColumns >= 0) {
visualizationOptions.minSizeX = minColumns;
}
const maxColumns = parseInt(options.maxColumns, 10);
if (isFinite(maxColumns) && (maxColumns >= 0)) {
if (isFinite(maxColumns) && maxColumns >= 0) {
visualizationOptions.maxSizeX = Math.min(maxColumns, dashboardGridOptions.columns);
}
@@ -84,17 +100,17 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
visualizationOptions.minSizeY = minRows;
}
const maxRows = parseInt(options.maxRows, 10);
if (isFinite(maxRows) && (maxRows >= 0)) {
if (isFinite(maxRows) && maxRows >= 0) {
visualizationOptions.maxSizeY = maxRows;
}
// Default dimensions
const defaultWidth = parseInt(options.defaultColumns, 10);
if (isFinite(defaultWidth) && (defaultWidth > 0)) {
if (isFinite(defaultWidth) && defaultWidth > 0) {
visualizationOptions.sizeX = defaultWidth;
}
const defaultHeight = parseInt(options.defaultRows, 10);
if (isFinite(defaultHeight) && (defaultHeight > 0)) {
if (isFinite(defaultHeight) && defaultHeight > 0) {
visualizationOptions.sizeY = defaultHeight;
}
}
@@ -110,13 +126,17 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
widget.options.position.autoHeight = true;
}
return new WidgetResource(widget);
const result = new WidgetResource(widget);
// Save original position (create a shallow copy)
result.$originalPosition = extend({}, result.options.position);
return result;
}
return WidgetConstructor;
}
export default function init(ngModule) {
ngModule.factory('Widget', Widget);
}

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import {
each, values, sortBy, pluck, identity, filter, map,
} from 'underscore';
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.
const BaseColors = {
@@ -33,7 +33,7 @@ export const ColorPalette = Object.assign({}, BaseColors, {
'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 ColorPaletteArray = values(BaseColors);
@@ -148,8 +148,10 @@ function calculateDimensions(series, options) {
const hasX = contains(values(options.columnMapping), 'x');
const hasY2 = !!find(series, (serie) => {
const serieOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
return (serieOptions.yAxis === 1) && (options.series.stacking === null);
const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
return (seriesOptions.yAxis === 1) && (
(options.series.stacking === null) || (seriesOptions.type === 'line')
);
});
return {
@@ -249,7 +251,10 @@ function prepareChartData(seriesList, options) {
sourceData,
};
if ((seriesOptions.yAxis === 1) && (options.series.stacking === null)) {
if (
(seriesOptions.yAxis === 1) &&
((options.series.stacking === null) || (seriesOptions.type === 'line'))
) {
plotlySeries.yaxis = 'y2';
}
@@ -261,6 +266,7 @@ function prepareChartData(seriesList, options) {
};
} else if (seriesOptions.type === 'box') {
plotlySeries.boxpoints = 'outliers';
plotlySeries.hoverinfo = false;
plotlySeries.marker = {
color: seriesColor,
size: 3,
@@ -326,6 +332,10 @@ export function prepareLayout(element, seriesList, options, data) {
type: getScaleType(options.xAxis.type),
};
if (options.sortX && result.xaxis.type === 'category') {
result.xaxis.categoryorder = 'category ascending';
}
if (!isUndefined(options.xAxis.labels)) {
result.xaxis.showticklabels = options.xAxis.labels.enabled;
}
@@ -373,14 +383,18 @@ export function prepareLayout(element, seriesList, options, data) {
function updateSeriesText(seriesList, options) {
each(seriesList, (series) => {
series.text = [];
series.sourceData.forEach((item) => {
let text = formatNumber(item.y);
if (item.yError !== undefined) {
text = `${text} \u00B1 ${formatNumber(item.yError)}`;
}
series.x.forEach((x) => {
let text = null;
const item = series.sourceData.get(x);
if (item) {
text = formatNumber(item.y);
if (item.yError !== undefined) {
text = `${text} \u00B1 ${formatNumber(item.yError)}`;
}
if (options.series.percentValues) {
text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
if (options.series.percentValues) {
text = `${formatPercent(Math.abs(item.yPercent))}% (${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

@@ -20,7 +20,8 @@ const DEFAULT_OPTIONS = {
totalColumn: 'total',
valueColumn: 'value',
defaultRows: -1,
autoHeight: true,
defaultRows: 8,
};
function groupData(sortedData) {

View File

@@ -9,7 +9,7 @@
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Step Column Dispaly Name</label>
<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>
@@ -21,7 +21,7 @@
</div>
</div>
<div class="form-group">
<label class="col-lg-6">Funnel Value Column Dispaly Name</label>
<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>

View File

@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerIconRetina from 'leaflet/dist/images/marker-icon-2x.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 editorTemplate from './map-editor.html';
@@ -23,14 +25,16 @@ L.Icon.Default.mergeOptions({
delete L.Icon.Default.prototype._getIconUrl;
function mapRenderer() {
return {
restrict: 'E',
template,
link($scope, elm) {
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 layers = {};
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('visualization.options', render, true);
$scope.$watch('visualization.options.height', resize);
},
};
}
@@ -297,7 +300,7 @@ export default function init(ngModule) {
VisualizationProvider.registerVisualization({
type: 'MAP',
name: 'Map',
name: 'Map (Markers)',
renderTemplate,
editorTemplate: editTemplate,
defaultOptions,

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ export default function init(ngModule) {
ngModule.directive('wordCloudRenderer', wordCloudRenderer);
const defaultOptions = {
defaultRows: -1,
defaultRows: 8,
};
ngModule.config((VisualizationProvider) => {

1315
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,15 @@
{
"name": "redash-client",
"version": "4.0.0-rc.1",
"version": "4.0.0",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {
"start": "webpack-dev-server",
"dev": "REDASH_BACKEND=https://dev.redashapp.com npm start",
"build": "rm -rf ./client/dist/ && NODE_ENV=production node node_modules/.bin/webpack",
"watch": "webpack --watch --progress --colors -d"
"build": "rm -rf ./client/dist/ && NODE_ENV=production webpack",
"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": {
"type": "git",
@@ -26,7 +28,6 @@
"dependencies": {
"angular": "~1.5.8",
"angular-base64-upload": "^0.1.23",
"angular-gridster": "^0.13.14",
"angular-messages": "~1.5.8",
"angular-moment": "^1.1.0",
"angular-resizable": "^1.2.0",
@@ -39,16 +40,19 @@
"angular-vs-repeat": "^1.1.7",
"bootstrap": "^3.3.7",
"brace": "^0.10.0",
"chroma-js": "^1.3.6",
"core-js": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz",
"cornelius": "git+https://github.com/restorando/cornelius.git",
"d3": "^3.5.17",
"d3-cloud": "^1.2.4",
"debug": "^3.1.0",
"font-awesome": "^4.7.0",
"gridstack": "^0.3.0",
"jquery": "^3.2.1",
"jquery-ui": "^1.12.1",
"leaflet": "^1.2.0",
"leaflet.markercluster": "^1.1.0",
"leaflet-fullscreen": "^1.0.2",
"markdown": "0.5.0",
"material-design-iconic-font": "^2.2.0",
"moment": "^2.19.3",
@@ -88,6 +92,7 @@
"url-loader": "^0.5.9",
"webpack": "^3.6.0",
"webpack-build-notifier": "^0.1.16",
"webpack-bundle-analyzer": "^2.11.1",
"webpack-dev-server": "^2.9.1",
"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
__version__ = '4.0.0-rc.1'
__version__ = '4.0.0'
def setup_logging():

View File

@@ -45,6 +45,7 @@ def login(org_slug=None):
org_slug=org_slug,
next=next_path,
email=request.form.get('email', ''),
show_password_login=True,
username_prompt=settings.LDAP_CUSTOM_USERNAME_PROMPT,
hide_forgot_password=True)

View File

@@ -0,0 +1,58 @@
import json
import logging
import requests
from redash.destinations import *
class Mattermost(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
'type': 'object',
'properties': {
'url': {
'type': 'string',
'title': 'Mattermost Webhook URL'
},
'username': {
'type': 'string',
'title': 'Username'
},
'icon_url': {
'type': 'string',
'title': 'Icon (URL)'
},
'channel': {
'type': 'string',
'title': 'Channel'
}
}
}
@classmethod
def icon(cls):
return 'fa-bolt'
def notify(self, alert, query, user, new_state, app, host, options):
if new_state == "triggered":
text = "####" + alert.name + " just triggered"
else:
text = "####" + alert.name + " went back to normal"
payload = {'text': text}
if options.get('username'): payload['username'] = options.get('username')
if options.get('icon_url'): payload['icon_url'] = options.get('icon_url')
if options.get('channel'): payload['channel'] = options.get('channel')
try:
resp = requests.post(options.get('url'), data=json.dumps(payload))
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Mattermost webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("Mattermost webhook send ERROR.")
register(Mattermost)

View File

@@ -1,10 +1,11 @@
import json
from flask import request
from flask_login import login_required
from flask_login import current_user, login_required
from redash import models
from redash.handlers import routes
from redash.handlers.base import json_response, org_scoped_rule
from redash.authentication import current_org
from redash.permissions import require_admin
@@ -12,10 +13,10 @@ from redash.permissions import require_admin
@login_required
def organization_status(org_slug=None):
counters = {
'users': models.User.query.count(),
'alerts': models.Alert.query.count(),
'data_sources': models.DataSource.query.count(),
'queries': models.Query.query.filter(models.Query.is_archived==False).count(),
'dashboards': models.Dashboard.query.filter(models.Dashboard.is_archived==False).count(),
'users': models.User.all(current_org).count(),
'alerts': models.Alert.all(group_ids=current_user.group_ids).count(),
'data_sources': models.DataSource.all(current_org, group_ids=current_user.group_ids).count(),
'queries': models.Query.all_queries(current_user.group_ids, current_user.id, drafts=True).count(),
'dashboards': models.Dashboard.all(current_org, current_user.group_ids, current_user.id).count(),
}
return json_response(dict(object_counters=counters))

View File

@@ -54,7 +54,7 @@ def run_query_sync(data_source, parameter_values, query_text, max_age=0):
return None
run_time = time.time() - started_at
query_result, updated_query_ids = models.QueryResult.store_result(data_source.org, data_source,
query_result, updated_query_ids = models.QueryResult.store_result(data_source.org_id, data_source,
query_hash, query_text, data,
run_time, utils.utcnow())

View File

@@ -298,6 +298,8 @@ class Organization(TimestampMixin, db.Model):
slug = Column(db.String(255), unique=True)
settings = Column(MutableDict.as_mutable(PseudoJSON))
groups = db.relationship("Group", lazy="dynamic")
events = db.relationship("Event", lazy="dynamic", order_by="desc(Event.created_at)",)
__tablename__ = 'organizations'
@@ -751,7 +753,7 @@ class QueryResult(db.Model, BelongsToOrgMixin):
@classmethod
def store_result(cls, org, data_source, query_hash, query, data, run_time, retrieved_at):
query_result = cls(org=org,
query_result = cls(org_id=org,
query_hash=query_hash,
query_text=query,
runtime=run_time,
@@ -1479,7 +1481,7 @@ class Widget(TimestampMixin, db.Model):
class Event(db.Model):
id = Column(db.Integer, primary_key=True)
org_id = Column(db.Integer, db.ForeignKey("organizations.id"))
org = db.relationship(Organization, backref="events")
org = db.relationship(Organization, back_populates="events")
user_id = Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
user = db.relationship(User, backref="events")
action = Column(db.String(255))

View File

@@ -30,5 +30,10 @@ def get_status():
'data_sources': ', '.join(sources),
'size': redis_connection.llen(queue)
}
status['manager']['queues']['celery'] = {
'size': redis_connection.llen('celery'),
'data_sources': ''
}
return status

View File

@@ -173,8 +173,7 @@ class BigQuery(BaseQueryRunner):
if self.configuration.get('useStandardSql', False):
job_data['configuration']['query']['useLegacySql'] = False
if "userDefinedFunctionResourceUri" in self.configuration:
if self.configuration.get('userDefinedFunctionResourceUri'):
resource_uris = self.configuration["userDefinedFunctionResourceUri"].split(',')
job_data["configuration"]["query"]["userDefinedFunctionResources"] = map(
lambda resource_uri: {"resourceUri": resource_uri}, resource_uris)

View File

@@ -105,7 +105,11 @@ class DynamoDBSQL(BaseSQLQueryRunner):
# When running a count query it returns the value as a string, in which case
# we transform it into a dictionary to be the same as regular queries.
if isinstance(result, basestring):
result = [{"value": result}]
# when count < scanned_count, dql returns a string with number of rows scanned
value = result.split(" (")[0]
if value:
value = int(value)
result = [{"value": value}]
for item in result:
if not columns:

View File

@@ -158,7 +158,8 @@ class MongoDB(BaseQueryRunner):
def _get_db(self):
if self.is_replica_set:
db_connection = pymongo.MongoReplicaSetClient(self.configuration["connectionString"], replicaSet=self.configuration["replicaSetName"])
db_connection = pymongo.MongoClient(self.configuration["connectionString"],
replicaSet=self.configuration["replicaSetName"])
else:
db_connection = pymongo.MongoClient(self.configuration["connectionString"])

View File

@@ -17,7 +17,7 @@ except ImportError:
# from _mssql.pyx ## DB-API type definitions & http://www.freetds.org/tds.html#types ##
types_map = {
1: TYPE_STRING,
2: TYPE_BOOLEAN,
2: TYPE_STRING,
# Type #3 supposed to be an integer, but in some cases decimals are returned
# with this type. To be on safe side, marking it as float.
3: TYPE_FLOAT,

View File

@@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
try:
import tdclient
from tdclient import errors
enabled = True
except ImportError:
@@ -102,22 +103,24 @@ class TreasureData(BaseQueryRunner):
db=self.configuration.get('db'))
cursor = connection.cursor()
try:
cursor.execute(query)
columns_data = [(row[0], cursor.show_job()['hive_result_schema'][i][1]) for i,row in enumerate(cursor.description)]
cursor.execute(query)
columns_data = [(row[0], cursor.show_job()['hive_result_schema'][i][1]) for i,row in enumerate(cursor.description)]
columns = [{'name': col[0],
'friendly_name': col[0],
'type': TD_TYPES_MAPPING.get(col[1], None)} for col in columns_data]
if cursor.rowcount == 0:
rows = []
else:
rows = [dict(zip(([c[0] for c in columns_data]), r)) for i, r in enumerate(cursor.fetchall())]
data = {'columns': columns, 'rows': rows}
json_data = json.dumps(data, cls=JSONEncoder)
error = None
columns = [{'name': col[0],
'friendly_name': col[0],
'type': TD_TYPES_MAPPING.get(col[1], None)} for col in columns_data]
if cursor.rowcount == 0:
rows = []
else:
rows = [dict(zip(([c[0] for c in columns_data]), r)) for i, r in enumerate(cursor.fetchall())]
data = {'columns': columns, 'rows': rows}
json_data = json.dumps(data, cls=JSONEncoder)
error = None
except errors.InternalError as e:
json_data = None
error = "%s: %s" % (e.message, cursor.show_job().get('debug', {}).get('stderr', 'No stderr message in the response'))
return json_data, error
register(TreasureData)

View File

@@ -185,6 +185,7 @@ default_destinations = [
'redash.destinations.slack',
'redash.destinations.webhook',
'redash.destinations.hipchat',
'redash.destinations.mattermost',
]
enabled_destinations = array_from_string(os.environ.get("REDASH_ENABLED_DESTINATIONS", ",".join(default_destinations)))

View File

@@ -33,7 +33,7 @@ def should_notify(alert, new_state):
return new_state != alert.state or (alert.state == models.Alert.TRIGGERED_STATE and passed_rearm_threshold)
@celery.task(name="redash.tasks.check_alerts_for_query")
@celery.task(name="redash.tasks.check_alerts_for_query", time_limit=300, soft_time_limit=240)
def check_alerts_for_query(query_id):
logger.debug("Checking query %d for alerts", query_id)

View File

@@ -422,6 +422,8 @@ class QueryExecutor(object):
self.user = models.User.query.get(user_id)
else:
self.user = None
# Close DB connection to prevent holding a connection for a long time while the query is executing.
models.db.session.close()
self.query_hash = gen_query_hash(self.query)
self.scheduled_query = scheduled_query
# Load existing tracker or create a new one if the job was created before code update:
@@ -460,16 +462,17 @@ class QueryExecutor(object):
if error:
self.tracker.update(state='failed')
result = QueryExecutionError(error)
if self.scheduled_query:
if self.scheduled_query is not None:
self.scheduled_query = models.db.session.merge(self.scheduled_query, load=False)
self.scheduled_query.schedule_failures += 1
models.db.session.add(self.scheduled_query)
else:
if (self.scheduled_query and
self.scheduled_query.schedule_failures > 0):
if (self.scheduled_query and self.scheduled_query.schedule_failures > 0):
self.scheduled_query = models.db.session.merge(self.scheduled_query, load=False)
self.scheduled_query.schedule_failures = 0
models.db.session.add(self.scheduled_query)
query_result, updated_query_ids = models.QueryResult.store_result(
self.data_source.org, self.data_source,
self.data_source.org_id, self.data_source,
self.query_hash, self.query, data,
run_time, utils.utcnow())
self._log_progress('checking_alerts')

View File

@@ -5,13 +5,13 @@ influxdb==2.7.1
MySQL-python==1.2.5
oauth2client==3.0.0
pyhive==0.3.0
pymongo==3.2.1
pymongo==3.6.1
vertica-python==0.5.1
td-client==0.8.0
pymssql==2.1.3
dql==0.5.24
dynamo3==0.4.7
botocore==1.8.6
botocore==1.10.2
sasl>=0.1.3
thrift>=0.8.0
thrift_sasl>=0.1.0

View File

@@ -5,4 +5,4 @@ coverage==4.0.3
mock==2.0.0
# PyMongo is needed for one of the unit tests:
pymongo==3.2.1
pymongo==3.6.1

View File

@@ -115,6 +115,7 @@ class QueryExecutorTests(BaseTestCase):
with cm, mock.patch.object(PostgreSQL, "run_query") as qr:
qr.exception = ValueError("broken")
execute_query("SELECT 1, 2", self.factory.data_source.id, {}, scheduled_query_id=q.id)
q = models.Query.get_by_id(q.id)
self.assertEqual(q.schedule_failures, 1)
execute_query("SELECT 1, 2", self.factory.data_source.id, {}, scheduled_query_id=q.id)
q = models.Query.get_by_id(q.id)

View File

@@ -208,7 +208,7 @@ class QueryArchiveTest(BaseTestCase):
query = self.factory.create_query(schedule="1")
yesterday = utcnow() - datetime.timedelta(days=1)
query_result, _ = models.QueryResult.store_result(
query.org, query.data_source, query.query_hash, query.query_text,
query.org_id, query.data_source, query.query_hash, query.query_text,
"1", 123, yesterday)
query.latest_query_data = query_result
@@ -390,7 +390,7 @@ class TestQueryResultStoreResult(BaseTestCase):
def test_stores_the_result(self):
query_result, _ = models.QueryResult.store_result(
self.data_source.org, self.data_source, self.query_hash,
self.data_source.org_id, self.data_source, self.query_hash,
self.query, self.data, self.runtime, self.utcnow)
self.assertEqual(query_result.data, self.data)
@@ -406,7 +406,7 @@ class TestQueryResultStoreResult(BaseTestCase):
query3 = self.factory.create_query(query_text=self.query)
query_result, _ = models.QueryResult.store_result(
self.data_source.org, self.data_source, self.query_hash,
self.data_source.org_id, self.data_source, self.query_hash,
self.query, self.data, self.runtime, self.utcnow)
self.assertEqual(query1.latest_query_data, query_result)
@@ -419,7 +419,7 @@ class TestQueryResultStoreResult(BaseTestCase):
query3 = self.factory.create_query(query_text=self.query + "123")
query_result, _ = models.QueryResult.store_result(
self.data_source.org, self.data_source, self.query_hash,
self.data_source.org_id, self.data_source, self.query_hash,
self.query, self.data, self.runtime, self.utcnow)
self.assertEqual(query1.latest_query_data, query_result)
@@ -432,7 +432,7 @@ class TestQueryResultStoreResult(BaseTestCase):
query3 = self.factory.create_query(query_text=self.query, data_source=self.factory.create_data_source())
query_result, _ = models.QueryResult.store_result(
self.data_source.org, self.data_source, self.query_hash,
self.data_source.org_id, self.data_source, self.query_hash,
self.query, self.data, self.runtime, self.utcnow)
self.assertEqual(query1.latest_query_data, query_result)

View File

@@ -8,6 +8,7 @@ const WebpackBuildNotifierPlugin = require('webpack-build-notifier');
const ManifestPlugin = require('webpack-manifest-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const LessPluginAutoPrefix = require('less-plugin-autoprefix');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const path = require('path');
const redashBackend = process.env.REDASH_BACKEND || 'http://localhost:5000';
@@ -32,7 +33,12 @@ const config = {
},
resolve: {
alias: {
'@': appPath
'@': appPath,
// Currently `lodash` is used only by `gridstack.js`, but it can work
// with `underscore` as well, so set an alias to avoid bundling both `lodash` and
// `underscore`. When adding new libraries, check if they can work
// with `underscore`, otherwise remove this line
'lodash': 'underscore',
}
},
plugins: [
@@ -42,6 +48,8 @@ const config = {
}),
// Enforce angular to use jQuery instead of jqLite
new webpack.ProvidePlugin({'window.jQuery': 'jquery'}),
// bundle only default `moment` locale (`en`)
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
@@ -136,6 +144,16 @@ const config = {
}
}]
},
{
test: /\.geo\.json$/,
use: [{
loader: 'file-loader',
options: {
outputPath: 'data/',
name: '[hash:7].[name].[ext]',
}
}]
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
use: [{
@@ -153,6 +171,9 @@ const config = {
modules: false,
chunkModules: false,
},
watchOptions: {
ignored: /\.sw.$/,
},
devServer: {
inline: true,
index: '/static/index.html',
@@ -201,4 +222,8 @@ if (process.env.NODE_ENV === 'production') {
config.devtool = 'source-map';
}
if (process.env.BUNDLE_ANALYZER) {
config.plugins.push(new BundleAnalyzerPlugin());
}
module.exports = config;