Skip to content

Commit

Permalink
Dashboard auto-saving (#3653)
Browse files Browse the repository at this point in the history
  • Loading branch information
ranbena authored Apr 17, 2019
1 parent 9b3dd82 commit 18761cf
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 195 deletions.
4 changes: 4 additions & 0 deletions client/app/assets/less/redash/redash-newstyle.less
Original file line number Diff line number Diff line change
Expand Up @@ -937,4 +937,8 @@ text.slicetext {

.markdown strong {
font-weight: bold;
}

.disabled-silent {
pointer-events: none;
}
73 changes: 5 additions & 68 deletions client/app/components/dashboards/gridstack/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function gridstack($parse, dashboardGridOptions) {
scope: {
editing: '=',
batchUpdate: '=', // set by directive - for using in wrapper components
onLayoutChanged: '=',
isOneColumnMode: '=',
},
controller() {
Expand Down Expand Up @@ -121,67 +122,6 @@ function gridstack($parse, dashboardGridOptions) {
});
};

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) {
Expand Down Expand Up @@ -235,9 +175,7 @@ function gridstack($parse, dashboardGridOptions) {
};
},
link: ($scope, $element, $attr, controller) => {
const batchUpdateAssignable = _.isFunction($parse($attr.batchUpdate).assign);
const isOneColumnModeAssignable = _.isFunction($parse($attr.batchUpdate).assign);

const isOneColumnModeAssignable = _.isFunction($parse($attr.onLayoutChanged).assign);
let enablePolling = true;

$element.addClass('grid-stack');
Expand Down Expand Up @@ -300,6 +238,9 @@ function gridstack($parse, dashboardGridOptions) {
$(node.el).trigger('gridstack.changed', node);
}
});
if ($scope.onLayoutChanged) {
$scope.onLayoutChanged();
}
changedNodes = {};
});

Expand All @@ -315,10 +256,6 @@ function gridstack($parse, dashboardGridOptions) {
controller.setEditing(!!value);
});

if (batchUpdateAssignable) {
$scope.batchUpdate = controller.batchUpdateWidgets;
}

$scope.$on('$destroy', () => {
enablePolling = false;
controller.$el = null;
Expand Down
18 changes: 8 additions & 10 deletions client/app/pages/dashboards/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,15 @@ <h3>
</div>
<div class="col-xs-4 col-sm-5 col-lg-5 text-right dashboard__control p-r-0">
<span ng-if="!$ctrl.dashboard.is_archived && !public" class="hidden-print">
<div class="btn-group">
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.isGridDisabled"
ng-click="$ctrl.editLayout(false, true)" ng-if="$ctrl.layoutEditing">
<i class="zmdi zmdi-check"></i> Apply Changes
</button>
<div ng-if="$ctrl.layoutEditing" ng-switch="$ctrl.isLayoutDirty || $ctrl.saveInProgress">
<span class="save-status" data-saving ng-switch-when="true">Saving</span>
<span class="save-status" ng-switch-default>Saved</span>

<button type="button" class="btn btn-default btn-sm"
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="$ctrl.isGridDisabled"
ng-click="$ctrl.editLayout(false, false)" ng-if="$ctrl.layoutEditing">
<i class="zmdi zmdi-close"></i> Cancel
ng-click="$ctrl.editLayout(false)"
ng-class="{'disabled-silent': $ctrl.isLayoutDirty || $ctrl.saveInProgress }">
<i class="fa fa-check"></i> Done Editing
</button>
</div>

Expand Down Expand Up @@ -91,7 +89,7 @@ <h3>
</div>

<div style="padding-bottom: 5px;" ng-if="$ctrl.dashboard.widgets.length > 0">
<div gridstack editing="$ctrl.layoutEditing && !$ctrl.saveInProgress" batch-update="$ctrl.updateGridItems"
<div gridstack editing="$ctrl.layoutEditing" on-layout-changed="$ctrl.onLayoutChanged"
is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper"
ng-class="{'preview-mode': !$ctrl.layoutEditing, 'editing-mode': $ctrl.layoutEditing}">
<div class="dashboard-widget-wrapper"
Expand Down
68 changes: 30 additions & 38 deletions client/app/pages/dashboards/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,34 +48,37 @@ function DashboardCtrl(
) {
this.saveInProgress = false;

const saveDashboardLayout = (widgets) => {
const saveDashboardLayout = () => {
if (!this.dashboard.canEdit()) {
return;
}

// calc diff, bail if none
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
if (!changedWidgets.length) {
this.isLayoutDirty = false;
$scope.$applyAsync();
return;
}

this.saveInProgress = true;
const showMessages = true;
return $q
.all(_.map(widgets, widget => widget.save()))
.all(_.map(changedWidgets, widget => widget.save()))
.then(() => {
if (showMessages) {
notification.success('Changes saved.');
}
// Update original widgets positions
_.each(widgets, (widget) => {
_.extend(widget.$originalPosition, widget.options.position);
});
this.isLayoutDirty = false;
})
.catch(() => {
if (showMessages) {
notification.error('Error saving changes.');
}
// in the off-chance that a widget got deleted mid-saving it's position, an error will occur
// currently left unhandled PR 3653#issuecomment-481699053
notification.error('Error saving changes.');
})
.finally(() => {
this.saveInProgress = false;
});
};

const saveDashboardLayoutDebounced = _.debounce(saveDashboardLayout, 2000);

this.layoutEditing = false;
this.isFullscreen = false;
this.refreshRate = null;
Expand All @@ -84,6 +87,7 @@ function DashboardCtrl(
this.showPermissionsControl = clientConfig.showPermissionsControl;
this.globalParameters = [];
this.isDashboardOwner = false;
this.isLayoutDirty = false;

this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
name: durationHumanize(interval),
Expand Down Expand Up @@ -242,28 +246,17 @@ function DashboardCtrl(
});
};

this.editLayout = (enableEditing, applyChanges) => {
if (!this.isGridDisabled) {
if (!enableEditing) {
if (applyChanges) {
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
saveDashboardLayout(changedWidgets);
} else {
// Revert changes
const items = {};
_.each(this.dashboard.widgets, (widget) => {
_.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);
}
}
}

this.layoutEditing = enableEditing;
this.onLayoutChanged = () => {
// prevent unnecessary save when gridstack is loaded
if (!this.layoutEditing) {
return;
}
this.isLayoutDirty = true;
saveDashboardLayoutDebounced();
};

this.editLayout = (enableEditing) => {
this.layoutEditing = enableEditing;
};

this.loadTags = () => getTags('api/dashboards/tags').then(tags => _.map(tags, t => t.name));
Expand Down Expand Up @@ -405,12 +398,11 @@ function DashboardCtrl(
this.removeWidget = (widgetId) => {
this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== widgetId);
this.extractGlobalParameters();
$scope.$applyAsync();

if (!this.layoutEditing) {
// We need to wait a bit while `angular` updates widgets, and only then save new layout
$timeout(() => {
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
saveDashboardLayout(changedWidgets);
}, 50);
$timeout(saveDashboardLayout, 50);
}
};

Expand Down
32 changes: 32 additions & 0 deletions client/app/pages/dashboards/dashboard.less
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,38 @@
}
}

.dashboard__control {
.save-status {
vertical-align: middle;
margin-right: 7px;
font-size: 12px;
text-align: left;
display: inline-block;

&[data-saving] {
opacity: 0.6;
width: 45px;

&:after {
content: '';
animation: saving 2s linear infinite;
}
}
}
}

@keyframes saving {
0%, 100% {
content: '.';
}
33% {
content: '..';
}
66% {
content: '...';
}
}


// Mobile fixes
@media (max-width: 767px) {
Expand Down
6 changes: 6 additions & 0 deletions client/app/services/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ function WidgetFactory($http, $location, Query, Visualization, dashboardGridOpti
this.options.position.autoHeight = true;
}

this.updateOriginalPosition();
}

updateOriginalPosition() {
// Save original position (create a shallow copy)
this.$originalPosition = extend({}, this.options.position);
}
Expand Down Expand Up @@ -161,6 +165,8 @@ function WidgetFactory($http, $location, Query, Visualization, dashboardGridOpti
this[k] = v;
});

this.updateOriginalPosition();

return this;
});
}
Expand Down
Loading

0 comments on commit 18761cf

Please sign in to comment.