mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Compare commits
80 Commits
v4.0.0-rc.
...
v4.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a66fae82 | ||
|
|
ed739e1292 | ||
|
|
212c7eed46 | ||
|
|
ce50042407 | ||
|
|
e17e36f9e4 | ||
|
|
0bc570d741 | ||
|
|
7465c74392 | ||
|
|
a8a91109ee | ||
|
|
add60c2552 | ||
|
|
4dc8826beb | ||
|
|
d35bbdb257 | ||
|
|
8636c3310d | ||
|
|
eddd9419a4 | ||
|
|
2d08314982 | ||
|
|
28d69b0c60 | ||
|
|
7f76400550 | ||
|
|
f551b348a7 | ||
|
|
b1567f4d8d | ||
|
|
d18c94a587 | ||
|
|
f75c142981 | ||
|
|
0959281a01 | ||
|
|
96a0a512f3 | ||
|
|
9899abfe6a | ||
|
|
d02386488c | ||
|
|
5f25bc480c | ||
|
|
07b5003c6f | ||
|
|
8aba5db862 | ||
|
|
b3ee25079e | ||
|
|
85179fd07b | ||
|
|
390360cc4e | ||
|
|
7edd5b9731 | ||
|
|
c681a50b19 | ||
|
|
8df2391a77 | ||
|
|
0982e56ed0 | ||
|
|
0cb995bb35 | ||
|
|
d34d58bf33 | ||
|
|
c19ff41392 | ||
|
|
abb6e56570 | ||
|
|
a7bba81969 | ||
|
|
6356a75478 | ||
|
|
61ef5f9a02 | ||
|
|
2fbf8926c4 | ||
|
|
ce9e3fcb35 | ||
|
|
ffab6d5ec9 | ||
|
|
be9bcaeb3d | ||
|
|
d140e0418f | ||
|
|
6685cb9e21 | ||
|
|
2f24cff33c | ||
|
|
193a6cce3f | ||
|
|
17951504f0 | ||
|
|
ccffe70359 | ||
|
|
503d6cecd0 | ||
|
|
6fbe06d262 | ||
|
|
2394f3fbe5 | ||
|
|
cb815f3c8e | ||
|
|
e6f6c02f90 | ||
|
|
565e66715f | ||
|
|
549de1355a | ||
|
|
d892ed48cc | ||
|
|
b96204654b | ||
|
|
3c75c2bb60 | ||
|
|
db020576ed | ||
|
|
5a93da3177 | ||
|
|
d16285d239 | ||
|
|
0410d834d1 | ||
|
|
b79abf52fd | ||
|
|
6a61057813 | ||
|
|
1a75d49041 | ||
|
|
c054731794 | ||
|
|
a824bd5da3 | ||
|
|
e1ff31718e | ||
|
|
797b5582ac | ||
|
|
452904398f | ||
|
|
517f95fa01 | ||
|
|
d5ee9cd007 | ||
|
|
5918253022 | ||
|
|
2f30dbf645 | ||
|
|
88deb5fc47 | ||
|
|
27c7e86297 | ||
|
|
051f12c712 |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
client/app/assets/images/db-logos/Cassandra.png
Normal file
BIN
client/app/assets/images/db-logos/Cassandra.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
client/app/assets/images/db-logos/scylla.png
Normal file
BIN
client/app/assets/images/db-logos/scylla.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
client/app/assets/images/destinations/mattermost.png
Normal file
BIN
client/app/assets/images/destinations/mattermost.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
87
client/app/components/dashboards/gridstack/gridstack.js
Normal file
87
client/app/components/dashboards/gridstack/gridstack.js
Normal 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;
|
||||
};
|
||||
55
client/app/components/dashboards/gridstack/gridstack.less
Normal file
55
client/app/components/dashboards/gridstack/gridstack.less
Normal 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;
|
||||
}
|
||||
}
|
||||
444
client/app/components/dashboards/gridstack/index.js
Normal file
444
client/app/components/dashboards/gridstack/index.js
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as _ from 'underscore';
|
||||
import template from './widget.html';
|
||||
import editTextBoxTemplate from './edit-text-box.html';
|
||||
import './widget.less';
|
||||
@@ -19,11 +18,15 @@ 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.widget
|
||||
.$save()
|
||||
.then(() => {
|
||||
this.close();
|
||||
}).catch(() => {
|
||||
})
|
||||
.catch(() => {
|
||||
toastr.error('Widget can not be updated');
|
||||
}).finally(() => {
|
||||
})
|
||||
.finally(() => {
|
||||
this.saveInProgress = false;
|
||||
});
|
||||
} else {
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
<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}">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,15 +84,19 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<div class="add-widget-container" ng-if="$ctrl.layoutEditing">
|
||||
<h2>
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,34 +35,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.gridster-preview-holder {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
&.gridster-mobile {
|
||||
margin: 0;
|
||||
|
||||
.gridster-item {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.gridster-mobile) {
|
||||
.tile {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gridster-item:not(.gridster-auto-height-enabled) {
|
||||
.dashboard-widget-wrapper:not(.widget-auto-height-enabled) {
|
||||
visualization-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -100,11 +80,14 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gridster-auto-height-enabled {
|
||||
.widget-auto-height-enabled {
|
||||
.spinner {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.scrollbox {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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' }, {
|
||||
const WidgetResource = $resource(
|
||||
'api/widgets/:id',
|
||||
{ id: '@id' },
|
||||
{
|
||||
get: { method: 'GET' },
|
||||
save: {
|
||||
method: 'POST',
|
||||
@@ -15,7 +18,8 @@ function Widget($resource, $http, Query, Visualization, dashboardGridOptions) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const PlotlyChart = () => ({
|
||||
}
|
||||
}, true);
|
||||
|
||||
scope.handleResize = debounce(updateChartDimensions, 100);
|
||||
scope.handleResize = debounce(updateChartDimensions, 50);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,8 +383,11 @@ 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);
|
||||
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)}`;
|
||||
}
|
||||
@@ -382,6 +395,7 @@ function updateSeriesText(seriesList, options) {
|
||||
if (options.series.percentValues) {
|
||||
text = `${formatPercent(Math.abs(item.yPercent))}% (${text})`;
|
||||
}
|
||||
}
|
||||
|
||||
series.text.push(text);
|
||||
});
|
||||
|
||||
251
client/app/visualizations/choropleth/choropleth-editor.html
Normal file
251
client/app/visualizations/choropleth/choropleth-editor.html
Normal 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="http://numeraljs.com/" target="_blank">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>
|
||||
11
client/app/visualizations/choropleth/choropleth.html
Normal file
11
client/app/visualizations/choropleth/choropleth.html
Normal 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>
|
||||
1
client/app/visualizations/choropleth/countries.geo.json
Normal file
1
client/app/visualizations/choropleth/countries.geo.json
Normal file
File diff suppressed because one or more lines are too long
311
client/app/visualizations/choropleth/index.js
Normal file
311
client/app/visualizations/choropleth/index.js
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
141
client/app/visualizations/choropleth/utils.js
Normal file
141
client/app/visualizations/choropleth/utils.js
Normal 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;
|
||||
}
|
||||
@@ -20,7 +20,8 @@ const DEFAULT_OPTIONS = {
|
||||
totalColumn: 'total',
|
||||
valueColumn: 'value',
|
||||
|
||||
defaultRows: -1,
|
||||
autoHeight: true,
|
||||
defaultRows: 8,
|
||||
};
|
||||
|
||||
function groupData(sortedData) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -57,7 +57,7 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
1315
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
58
redash/destinations/mattermost.py
Normal file
58
redash/destinations/mattermost.py
Normal 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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -31,4 +31,9 @@ def get_status():
|
||||
'size': redis_connection.llen(queue)
|
||||
}
|
||||
|
||||
status['manager']['queues']['celery'] = {
|
||||
'size': redis_connection.llen('celery'),
|
||||
'data_sources': ''
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import tdclient
|
||||
from tdclient import errors
|
||||
enabled = True
|
||||
|
||||
except ImportError:
|
||||
@@ -102,7 +103,7 @@ 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)]
|
||||
|
||||
@@ -117,7 +118,9 @@ class TreasureData(BaseQueryRunner):
|
||||
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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user