mirror of
https://github.com/getredash/redash.git
synced 2026-03-23 04:00:09 -04:00
445 lines
14 KiB
JavaScript
445 lines
14 KiB
JavaScript
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);
|
|
}
|