Commit 4d420a0c by Torkel Ödegaard

refactor(): dashboard row model and hunting down memory leak

parent 57cbefdf
...@@ -44,8 +44,9 @@ export function infoPopover() { ...@@ -44,8 +44,9 @@ export function infoPopover() {
} }
}); });
scope.$on('$destroy', function() { var unbind = scope.$on('$destroy', function() {
drop.destroy(); drop.destroy();
unbind();
}); });
}); });
......
...@@ -42,6 +42,8 @@ import './filters/filters'; ...@@ -42,6 +42,8 @@ import './filters/filters';
import coreModule from './core_module'; import coreModule from './core_module';
import appEvents from './app_events'; import appEvents from './app_events';
import colors from './utils/colors'; import colors from './utils/colors';
import {assignModelProperties} from './utils/model_utils';
import {contextSrv} from './services/context_srv';
export { export {
...@@ -62,4 +64,6 @@ export { ...@@ -62,4 +64,6 @@ export {
queryPartEditorDirective, queryPartEditorDirective,
WizardFlow, WizardFlow,
colors, colors,
assignModelProperties,
contextSrv,
}; };
...@@ -23,12 +23,17 @@ export class Emitter { ...@@ -23,12 +23,17 @@ export class Emitter {
this.emitter.on(name, handler); this.emitter.on(name, handler);
if (scope) { if (scope) {
scope.$on('$destroy', () => { var unbind = scope.$on('$destroy', () => {
this.emitter.off(name, handler); this.emitter.off(name, handler);
unbind();
}); });
} }
} }
removeAllListeners(evt?) {
this.emitter.removeAllListeners(evt);
}
off(name, handler) { off(name, handler) {
this.emitter.off(name, handler); this.emitter.off(name, handler);
} }
......
export function assignModelProperties(target, source, defaults) {
for (var key in defaults) {
if (!defaults.hasOwnProperty(key)) {
continue;
}
target[key] = source[key] === undefined ? defaults[key] : source[key];
}
}
...@@ -52,11 +52,12 @@ export class AlertTabCtrl { ...@@ -52,11 +52,12 @@ export class AlertTabCtrl {
var thresholdChangedEventHandler = this.graphThresholdChanged.bind(this); var thresholdChangedEventHandler = this.graphThresholdChanged.bind(this);
this.panelCtrl.events.on('threshold-changed', thresholdChangedEventHandler); this.panelCtrl.events.on('threshold-changed', thresholdChangedEventHandler);
// set panel alert edit mode // set panel alert edit mode
this.$scope.$on("$destroy", () => { var unbind = this.$scope.$on("$destroy", () => {
this.panelCtrl.events.off("threshold-changed", thresholdChangedEventHandler); this.panelCtrl.events.off("threshold-changed", thresholdChangedEventHandler);
this.panelCtrl.editingThresholds = false; this.panelCtrl.editingThresholds = false;
this.panelCtrl.render(); this.panelCtrl.render();
unbind();
}); });
// build notification model // build notification model
......
...@@ -102,12 +102,7 @@ export class DashboardCtrl { ...@@ -102,12 +102,7 @@ export class DashboardCtrl {
}; };
$scope.addRowDefault = function() { $scope.addRowDefault = function() {
$scope.dashboard.rows.push({ $scope.dashboard.addEmptyRow();
title: 'New row',
panels: [],
height: '250px',
isNew: true,
});
}; };
$scope.showJsonEditor = function(evt, options) { $scope.showJsonEditor = function(evt, options) {
...@@ -122,8 +117,9 @@ export class DashboardCtrl { ...@@ -122,8 +117,9 @@ export class DashboardCtrl {
$timeout.cancel(resizeEventTimeout); $timeout.cancel(resizeEventTimeout);
resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200); resizeEventTimeout = $timeout(function() { $scope.$broadcast('render'); }, 200);
}); });
$scope.$on('$destroy', function() { var unbind = $scope.$on('$destroy', function() {
angular.element(window).unbind('resize'); angular.element(window).unbind('resize');
unbind();
}); });
}; };
......
...@@ -11,8 +11,9 @@ function(angular, $) { ...@@ -11,8 +11,9 @@ function(angular, $) {
this.shortcuts = function(scope) { this.shortcuts = function(scope) {
scope.$on('$destroy', function() { var unbindDestroy = scope.$on('$destroy', function() {
keyboardManager.unbindAll(); keyboardManager.unbindAll();
unbindDestroy();
}); });
var helpModalScope = null; var helpModalScope = null;
...@@ -28,7 +29,11 @@ function(angular, $) { ...@@ -28,7 +29,11 @@ function(angular, $) {
keyboard: false keyboard: false
}); });
helpModalScope.$on('$destroy', function() { helpModalScope = null; }); var unbindModalDestroy = helpModalScope.$on('$destroy', function() {
helpModalScope = null;
unbindModalDestroy();
});
$q.when(helpModal).then(function(modalEl) { modalEl.modal('show'); }); $q.when(helpModal).then(function(modalEl) { modalEl.modal('show'); });
}, { inputDisabled: true }); }, { inputDisabled: true });
......
...@@ -6,8 +6,8 @@ import moment from 'moment'; ...@@ -6,8 +6,8 @@ import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
import $ from 'jquery'; import $ from 'jquery';
import {Emitter} from 'app/core/core'; import {Emitter, contextSrv} from 'app/core/core';
import {contextSrv} from 'app/core/services/context_srv'; import {DashboardRow} from './row/row_model';
export class DashboardModel { export class DashboardModel {
id: any; id: any;
...@@ -19,7 +19,7 @@ export class DashboardModel { ...@@ -19,7 +19,7 @@ export class DashboardModel {
timezone: any; timezone: any;
editable: any; editable: any;
sharedCrosshair: any; sharedCrosshair: any;
rows: any; rows: DashboardRow[];
time: any; time: any;
timepicker: any; timepicker: any;
templating: any; templating: any;
...@@ -51,7 +51,6 @@ export class DashboardModel { ...@@ -51,7 +51,6 @@ export class DashboardModel {
this.timezone = data.timezone || ''; this.timezone = data.timezone || '';
this.editable = data.editable !== false; this.editable = data.editable !== false;
this.sharedCrosshair = data.sharedCrosshair || false; this.sharedCrosshair = data.sharedCrosshair || false;
this.rows = data.rows || [];
this.time = data.time || { from: 'now-6h', to: 'now' }; this.time = data.time || { from: 'now-6h', to: 'now' };
this.timepicker = data.timepicker || {}; this.timepicker = data.timepicker || {};
this.templating = this.ensureListExist(data.templating); this.templating = this.ensureListExist(data.templating);
...@@ -63,10 +62,15 @@ export class DashboardModel { ...@@ -63,10 +62,15 @@ export class DashboardModel {
this.links = data.links || []; this.links = data.links || [];
this.gnetId = data.gnetId || null; this.gnetId = data.gnetId || null;
this.rows = [];
if (data.rows) {
for (let row of data.rows) {
this.rows.push(new DashboardRow(row));
}
}
this.updateSchema(data); this.updateSchema(data);
this.initMeta(meta); this.initMeta(meta);
this.editMode = this.meta.isNew;
} }
private initMeta(meta) { private initMeta(meta) {
...@@ -84,6 +88,7 @@ export class DashboardModel { ...@@ -84,6 +88,7 @@ export class DashboardModel {
} }
this.meta = meta; this.meta = meta;
this.editMode = this.meta.isNew;
} }
// cleans meta data and other non peristent state // cleans meta data and other non peristent state
...@@ -91,18 +96,27 @@ export class DashboardModel { ...@@ -91,18 +96,27 @@ export class DashboardModel {
// temp remove stuff // temp remove stuff
var events = this.events; var events = this.events;
var meta = this.meta; var meta = this.meta;
var rows = this.rows;
delete this.events; delete this.events;
delete this.meta; delete this.meta;
// prepare save model
this.rows = _.map(this.rows, row => row.getSaveModel());
events.emit('prepare-save-model'); events.emit('prepare-save-model');
var copy = $.extend(true, {}, this); var copy = $.extend(true, {}, this);
// restore properties // restore properties
this.events = events; this.events = events;
this.meta = meta; this.meta = meta;
this.rows = rows;
return copy; return copy;
} }
addEmptyRow() {
this.rows.push(new DashboardRow({isNew: true}));
}
private ensureListExist(data) { private ensureListExist(data) {
if (!data) { data = {}; } if (!data) { data = {}; }
if (!data.list) { data.list = []; } if (!data.list) { data.list = []; }
......
...@@ -19,7 +19,7 @@ export class DashRowCtrl { ...@@ -19,7 +19,7 @@ export class DashRowCtrl {
constructor(private $scope, private $rootScope, private $timeout, private uiSegmentSrv, private $q) { constructor(private $scope, private $rootScope, private $timeout, private uiSegmentSrv, private $q) {
this.row.title = this.row.title || 'Row title'; this.row.title = this.row.title || 'Row title';
if (this.dashboard.meta.isNew) { if (this.row.isNew) {
this.dropView = 1; this.dropView = 1;
delete this.row.isNew; delete this.row.isNew;
} }
...@@ -200,13 +200,19 @@ coreModule.directive('panelDropZone', function($timeout) { ...@@ -200,13 +200,19 @@ coreModule.directive('panelDropZone', function($timeout) {
} }
if (indrag === true) { if (indrag === true) {
return showPanel(dropZoneSpan, 'Drop Here'); var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
if (dropZoneSpan > 1) {
return showPanel(dropZoneSpan, 'Drop Here');
}
} }
hidePanel(); hidePanel();
} }
scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState); row.events.on('panel-added', updateState);
row.events.on('span-changed', updateState);
//scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState);
scope.$on("ANGULAR_DRAG_START", function() { scope.$on("ANGULAR_DRAG_START", function() {
indrag = true; indrag = true;
...@@ -220,6 +226,7 @@ coreModule.directive('panelDropZone', function($timeout) { ...@@ -220,6 +226,7 @@ coreModule.directive('panelDropZone', function($timeout) {
}); });
scope.$on("ANGULAR_DRAG_END", function() { scope.$on("ANGULAR_DRAG_END", function() {
console.log('drag end');
indrag = false; indrag = false;
updateState(); updateState();
}); });
......
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import $ from 'jquery';
import angular from 'angular';
import config from 'app/core/config';
import {coreModule} from 'app/core/core';
import './options';
import './add_panel';
export class DashRowCtrl {
dashboard: any;
row: any;
dropView: number;
/** @ngInject */
constructor(private $scope, private $rootScope, private $timeout, private uiSegmentSrv, private $q) {
this.row.title = this.row.title || 'Row title';
if (this.dashboard.meta.isNew) {
this.dropView = 1;
delete this.row.isNew;
}
}
onDrop(panelId, dropTarget) {
var info = this.dashboard.getPanelInfoById(panelId);
if (dropTarget) {
var dropInfo = this.dashboard.getPanelInfoById(dropTarget.id);
dropInfo.row.panels[dropInfo.index] = info.panel;
info.row.panels[info.index] = dropTarget;
var dragSpan = info.panel.span;
info.panel.span = dropTarget.span;
dropTarget.span = dragSpan;
} else {
info.row.panels.splice(info.index, 1);
info.panel.span = 12 - this.dashboard.rowSpan(this.row);
this.row.panels.push(info.panel);
}
this.$rootScope.$broadcast('render');
}
setHeight(height) {
this.row.height = height;
this.$scope.$broadcast('render');
}
moveRow(direction) {
var rowsList = this.dashboard.rows;
var rowIndex = _.indexOf(rowsList, this.row);
var newIndex = rowIndex;
switch (direction) {
case 'up': {
newIndex = rowIndex - 1;
break;
}
case 'down': {
newIndex = rowIndex + 1;
break;
}
case 'top': {
newIndex = 0;
break;
}
case 'bottom': {
newIndex = rowsList.length - 1;
break;
}
default: {
newIndex = rowIndex;
}
}
if (newIndex >= 0 && newIndex <= (rowsList.length - 1)) {
_.move(rowsList, rowIndex, newIndex);
}
}
toggleCollapse() {
this.dropView = 0;
this.row.collapse = !this.row.collapse;
}
showAddPanel() {
this.row.collapse = false;
this.dropView = this.dropView === 1 ? 0 : 1;
}
showRowOptions() {
this.dropView = this.dropView === 2 ? 0 : 2;
}
}
export function rowDirective($rootScope) {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/row/row.html',
controller: DashRowCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
dashboard: "=",
row: "=",
},
link: function(scope, element) {
scope.$watchGroup(['ctrl.row.collapse', 'ctrl.row.height'], function() {
element.find('.panels-wrapper').css({minHeight: scope.ctrl.row.collapse ? '5px' : scope.ctrl.row.height});
});
$rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) {
var hasPanel = _.find(scope.ctrl.row.panels, {id: info.panelId});
if (!hasPanel) {
element.hide();
}
}, scope);
$rootScope.onAppEvent('panel-fullscreen-exit', function() {
element.show();
}, scope);
}
};
}
coreModule.directive('dashRow', rowDirective);
coreModule.directive('panelWidth', function($rootScope) {
return function(scope, element) {
var fullscreen = false;
function updateWidth() {
if (!fullscreen) {
element[0].style.width = ((scope.panel.span / 1.2) * 10) + '%';
}
}
$rootScope.onAppEvent('panel-fullscreen-enter', function(evt, info) {
fullscreen = true;
if (scope.panel.id !== info.panelId) {
element.hide();
} else {
element[0].style.width = '100%';
}
}, scope);
$rootScope.onAppEvent('panel-fullscreen-exit', function(evt, info) {
fullscreen = false;
if (scope.panel.id !== info.panelId) {
element.show();
}
updateWidth();
}, scope);
scope.$watch('panel.span', updateWidth);
if (fullscreen) {
element.hide();
}
};
});
coreModule.directive('panelDropZone', function($timeout) {
return function(scope, element) {
var row = scope.ctrl.row;
var indrag = false;
var textEl = element.find('.panel-drop-zone-text');
function showPanel(span, text) {
element.find('.panel-container').css('height', row.height);
element[0].style.width = ((span / 1.2) * 10) + '%';
textEl.text(text);
element.show();
}
function hidePanel() {
element.hide();
// element.removeClass('panel-drop-zone--empty');
}
function updateState() {
if (scope.ctrl.dashboard.editMode) {
if (row.panels.length === 0 && indrag === false) {
return showPanel(12, 'Empty Space');
}
var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
if (dropZoneSpan > 1) {
if (indrag) {
return showPanel(dropZoneSpan, 'Drop Here');
} else {
return showPanel(dropZoneSpan, 'Empty Space');
}
}
}
if (indrag === true) {
return showPanel(dropZoneSpan, 'Drop Here');
}
hidePanel();
}
scope.row.events.on('panel-added', updateState);
scope.row.events.on('span-changed', updateState);
scope.$watchGroup(['ctrl.row.panels.length', 'ctrl.dashboard.editMode', 'ctrl.row.span'], updateState);
scope.$on("ANGULAR_DRAG_START", function() {
indrag = true;
updateState();
// $timeout(function() {
// var dropZoneSpan = 12 - scope.ctrl.dashboard.rowSpan(scope.ctrl.row);
// if (dropZoneSpan > 0) {
// showPanel(dropZoneSpan, 'Panel Drop Zone');
// }
// });
});
scope.$on("ANGULAR_DRAG_END", function() {
indrag = false;
updateState();
});
};
});
///<reference path="../../../headers/common.d.ts" />
import {Emitter, contextSrv} from 'app/core/core';
import {assignModelProperties} from 'app/core/core';
export class DashboardRow {
panels: any;
title: any;
showTitle: any;
titleSize: any;
events: Emitter;
defaults = {
title: 'Dashboard Row',
panels: [],
showTitle: false,
titleSize: 'h6',
height: 250,
isNew: false,
};
constructor(private model) {
assignModelProperties(this, model, this.defaults);
this.events = new Emitter();
}
getSaveModel() {
assignModelProperties(this.model, this, this.defaults);
return this.model;
}
}
...@@ -65,6 +65,7 @@ function(angular, _) { ...@@ -65,6 +65,7 @@ function(angular, _) {
dash.time = 0; dash.time = 0;
dash.refresh = 0; dash.refresh = 0;
dash.schemaVersion = 0; dash.schemaVersion = 0;
dash.editMode = false;
// filter row and panels properties that should be ignored // filter row and panels properties that should be ignored
dash.rows = _.filter(dash.rows, function(row) { dash.rows = _.filter(dash.rows, function(row) {
......
...@@ -199,8 +199,9 @@ function (angular, _, $) { ...@@ -199,8 +199,9 @@ function (angular, _, $) {
} }
} }
panelScope.$on('$destroy', function() { var unbind = panelScope.$on('$destroy', function() {
self.panelScopes = _.without(self.panelScopes, panelScope); self.panelScopes = _.without(self.panelScopes, panelScope);
unbind();
}); });
}; };
......
...@@ -50,7 +50,12 @@ export class PanelCtrl { ...@@ -50,7 +50,12 @@ export class PanelCtrl {
$scope.$on("refresh", () => this.refresh()); $scope.$on("refresh", () => this.refresh());
$scope.$on("render", () => this.render()); $scope.$on("render", () => this.render());
$scope.$on("$destroy", () => this.events.emit('panel-teardown'));
var unbindDestroy = $scope.$on("$destroy", () => {
this.events.emit('panel-teardown');
this.events.removeAllListeners();
unbindDestroy();
});
} }
init() { init() {
......
...@@ -100,7 +100,6 @@ module.directive('grafanaPanel', function() { ...@@ -100,7 +100,6 @@ module.directive('grafanaPanel', function() {
panelContainer.removeClass('panel-alert-state--' + lastAlertState); panelContainer.removeClass('panel-alert-state--' + lastAlertState);
lastAlertState = null; lastAlertState = null;
} }
}); });
scope.$watchGroup(['ctrl.fullscreen', 'ctrl.containerHeight'], function() { scope.$watchGroup(['ctrl.fullscreen', 'ctrl.containerHeight'], function() {
...@@ -189,8 +188,9 @@ module.directive('panelResizer', function($rootScope) { ...@@ -189,8 +188,9 @@ module.directive('panelResizer', function($rootScope) {
elem.on('mousedown', dragStartHandler); elem.on('mousedown', dragStartHandler);
scope.$on("$destroy", function() { var unbind = scope.$on("$destroy", function() {
elem.off('mousedown', dragStartHandler); elem.off('mousedown', dragStartHandler);
unbind();
}); });
} }
}; };
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import _ from 'lodash'; import _ from 'lodash';
import kbn from 'app/core/utils/kbn'; import kbn from 'app/core/utils/kbn';
import {assignModelProperties} from 'app/core/core';
export interface Variable { export interface Variable {
setValue(option); setValue(option);
...@@ -13,12 +14,9 @@ export interface Variable { ...@@ -13,12 +14,9 @@ export interface Variable {
} }
export var variableTypes = {}; export var variableTypes = {};
export {
export function assignModelProperties(target, source, defaults) { assignModelProperties
_.forEach(defaults, function(value, key) { };
target[key] = source[key] === undefined ? value : source[key];
});
}
export function containsVariable(...args: any[]) { export function containsVariable(...args: any[]) {
var variableName = args[args.length-1]; var variableName = args[args.length-1];
......
...@@ -34,6 +34,16 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) { ...@@ -34,6 +34,16 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
var rootScope = scope.$root; var rootScope = scope.$root;
var panelWidth = 0; var panelWidth = 0;
var thresholdManager = new ThresholdManager(ctrl); var thresholdManager = new ThresholdManager(ctrl);
var plot;
ctrl.events.on('panel-teardown', () => {
thresholdManager = null;
if (plot) {
plot.destroy();
plot = null;
}
});
rootScope.onAppEvent('setCrosshair', function(event, info) { rootScope.onAppEvent('setCrosshair', function(event, info) {
// do not need to to this if event is from this panel // do not need to to this if event is from this panel
...@@ -42,7 +52,6 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) { ...@@ -42,7 +52,6 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
} }
if (dashboard.sharedCrosshair) { if (dashboard.sharedCrosshair) {
var plot = elem.data().plot;
if (plot) { if (plot) {
plot.setCrosshair({ x: info.pos.x, y: info.pos.y }); plot.setCrosshair({ x: info.pos.x, y: info.pos.y });
} }
...@@ -50,10 +59,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) { ...@@ -50,10 +59,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
}, scope); }, scope);
rootScope.onAppEvent('clearCrosshair', function() { rootScope.onAppEvent('clearCrosshair', function() {
var plot = elem.data().plot; plot.clearCrosshair();
if (plot) {
plot.clearCrosshair();
}
}, scope); }, scope);
// Receive render events // Receive render events
...@@ -287,7 +293,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) { ...@@ -287,7 +293,7 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
function callPlot(incrementRenderCounter) { function callPlot(incrementRenderCounter) {
try { try {
$.plot(elem, sortedSeries, options); plot = $.plot(elem, sortedSeries, options);
if (ctrl.renderError) { if (ctrl.renderError) {
delete ctrl.error; delete ctrl.error;
delete ctrl.inspector; delete ctrl.inspector;
...@@ -529,9 +535,9 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) { ...@@ -529,9 +535,9 @@ module.directive('grafanaGraph', function($rootScope, timeSrv) {
return "%H:%M"; return "%H:%M";
} }
new GraphTooltip(elem, dashboard, scope, function() { // new GraphTooltip(elem, dashboard, scope, function() {
return sortedSeries; // return sortedSeries;
}); // });
elem.bind("plotselected", function (event, ranges) { elem.bind("plotselected", function (event, ranges) {
scope.$apply(function() { scope.$apply(function() {
......
...@@ -153,8 +153,8 @@ export class ThresholdManager { ...@@ -153,8 +153,8 @@ export class ThresholdManager {
this.renderHandle(1, this.height-30); this.renderHandle(1, this.height-30);
} }
this.placeholder.off('mousedown', '.alert-handle'); // this.placeholder.off('mousedown', '.alert-handle');
this.placeholder.on('mousedown', '.alert-handle', this.initDragging.bind(this)); // this.placeholder.on('mousedown', '.alert-handle', this.initDragging.bind(this));
this.needsCleanup = true; this.needsCleanup = true;
} }
......
...@@ -17,9 +17,10 @@ export class ThresholdFormCtrl { ...@@ -17,9 +17,10 @@ export class ThresholdFormCtrl {
this.disabled = true; this.disabled = true;
} }
$scope.$on("$destroy", () => { var unbindDestroy = $scope.$on("$destroy", () => {
this.panelCtrl.editingThresholds = false; this.panelCtrl.editingThresholds = false;
this.panelCtrl.render(); this.panelCtrl.render();
unbindDestroy();
}); });
this.panelCtrl.editingThresholds = true; this.panelCtrl.editingThresholds = true;
......
...@@ -212,8 +212,9 @@ class TablePanelCtrl extends MetricsPanelCtrl { ...@@ -212,8 +212,9 @@ class TablePanelCtrl extends MetricsPanelCtrl {
elem.on('click', '.table-panel-page-link', switchPage); elem.on('click', '.table-panel-page-link', switchPage);
scope.$on('$destroy', function() { var unbindDestroy = scope.$on('$destroy', function() {
elem.off('click', '.table-panel-page-link'); elem.off('click', '.table-panel-page-link');
unbindDestroy();
}); });
ctrl.events.on('render', function(renderData) { ctrl.events.on('render', function(renderData) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment