Dashboard grid React migration #1 (#3722)

* Dashboard grid React migration

* Updated tests

* Fixes comments

* One col layout

* Tests unskipped

* Test fixes

* Test fix

* AutoHeight feature

* Kebab-cased

* Get rid of lazyInjector

* Replace react-grid-layout with patched fork to fix performance issues

* Fix issue with initial layout when page has a scrollbar

* Decrease polling interval (500ms is too slow)

* Rename file to match it's contents

* Added some notes and very minor fixes

* Fix Remove widget button (should be visible only in editing mode); fix widget actions menu

* Fixed missing grid markings

* Enhanced resize handle

* Updated placeholder color

* Render DashboardGrid only when dashboard is loaded
This commit is contained in:
Ran Byron 2019-05-16 06:43:46 -06:00 committed by Arik Fraimovich
parent 4508975749
commit 606cf12e74
21 changed files with 575 additions and 714 deletions

View File

@ -0,0 +1,110 @@
import { includes, reduce, some } from 'lodash';
// TODO: Revisit this implementation when migrating widget component to React
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
const WIDGET_CONTENT_SELECTOR = [
'.widget-header', // header
'visualization-renderer', // visualization
'.scrollbox .alert', // error state
'.spinner-container', // loading state
'.tile__bottom-control', // footer
].join(',');
const INTERVAL = 200;
export default class AutoHeightController {
widgets = {};
interval = null;
onHeightChange = null;
constructor(handler) {
this.onHeightChange = handler;
}
update(widgets) {
const newWidgetIds = widgets
.filter(widget => widget.options.position.autoHeight)
.map(widget => widget.id.toString());
// added
newWidgetIds
.filter(id => !includes(Object.keys(this.widgets), id))
.forEach(this.add);
// removed
Object.keys(this.widgets)
.filter(id => !includes(newWidgetIds, id))
.forEach(this.remove);
}
add = (id) => {
if (this.isEmpty()) {
this.start();
}
const selector = WIDGET_SELECTOR.replace('{0}', id);
this.widgets[id] = [
function getHeight() {
const widgetEl = document.querySelector(selector);
if (!widgetEl) {
return undefined; // safety
}
// get all content elements
const els = widgetEl.querySelectorAll(WIDGET_CONTENT_SELECTOR);
// calculate accumulated height
return reduce(els, (acc, el) => {
const height = el ? el.getBoundingClientRect().height : 0;
return acc + height;
}, 0);
},
];
};
remove = (id) => {
// not actually deleting from this.widgets to prevent case of unwanted re-adding
this.widgets[id.toString()] = false;
if (this.isEmpty()) {
this.stop();
}
};
exists = id => !!this.widgets[id.toString()];
isEmpty = () => !some(this.widgets);
checkHeightChanges = () => {
Object.keys(this.widgets).forEach((id) => {
const [getHeight, prevHeight] = this.widgets[id];
const height = getHeight();
if (height && height !== prevHeight) {
this.widgets[id][1] = height; // save
this.onHeightChange(id, height); // dispatch
}
});
};
start = () => {
this.stop();
this.interval = setInterval(this.checkHeightChanges, INTERVAL);
};
stop = () => {
clearInterval(this.interval);
};
resume = () => {
if (!this.isEmpty()) {
this.start();
}
};
destroy = () => {
this.stop();
this.widgets = null;
}
}

View File

@ -0,0 +1,213 @@
import React from 'react';
import PropTypes from 'prop-types';
import { chain, cloneDeep, find } from 'lodash';
import { react2angular } from 'react2angular';
import cx from 'classnames';
import { Responsive, WidthProvider } from 'react-grid-layout';
import { DashboardWidget } from '@/components/dashboards/widget';
import { FiltersType } from '@/components/Filters';
import cfg from '@/config/dashboard-grid-options';
import AutoHeightController from './AutoHeightController';
import 'react-grid-layout/css/styles.css';
import './dashboard-grid.less';
const ResponsiveGridLayout = WidthProvider(Responsive);
const WidgetType = PropTypes.shape({
id: PropTypes.number.isRequired,
options: PropTypes.shape({
position: PropTypes.shape({
col: PropTypes.number.isRequired,
row: PropTypes.number.isRequired,
sizeY: PropTypes.number.isRequired,
minSizeY: PropTypes.number.isRequired,
maxSizeY: PropTypes.number.isRequired,
sizeX: PropTypes.number.isRequired,
minSizeX: PropTypes.number.isRequired,
maxSizeX: PropTypes.number.isRequired,
}).isRequired,
}).isRequired,
});
const SINGLE = 'single-column';
const MULTI = 'multi-column';
class DashboardGrid extends React.Component {
static propTypes = {
isEditing: PropTypes.bool.isRequired,
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
widgets: PropTypes.arrayOf(WidgetType).isRequired,
filters: FiltersType,
onBreakpointChange: PropTypes.func,
onRemoveWidget: PropTypes.func,
onLayoutChange: PropTypes.func,
};
static defaultProps = {
filters: [],
onRemoveWidget: () => {},
onLayoutChange: () => {},
onBreakpointChange: () => {},
};
static normalizeFrom(widget) {
const { id, options: { position: pos } } = widget;
return {
i: id.toString(),
x: pos.col,
y: pos.row,
w: pos.sizeX,
h: pos.sizeY,
minW: pos.minSizeX,
maxW: pos.maxSizeX,
minH: pos.minSizeY,
maxH: pos.maxSizeY,
};
}
mode = null;
autoHeightCtrl = null;
constructor(props) {
super(props);
this.state = {
layouts: {},
disableAnimations: true,
};
// init AutoHeightController
this.autoHeightCtrl = new AutoHeightController(this.onWidgetHeightUpdated);
this.autoHeightCtrl.update(this.props.widgets);
}
componentDidMount() {
this.onBreakpointChange(document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI);
// Work-around to disable initial animation on widgets; `measureBeforeMount` doesn't work properly:
// it disables animation, but it cannot detect scrollbars.
setTimeout(() => {
this.setState({ disableAnimations: false });
}, 50);
}
componentDidUpdate() {
// update, in case widgets added or removed
this.autoHeightCtrl.update(this.props.widgets);
}
componentWillUnmount() {
this.autoHeightCtrl.destroy();
}
onLayoutChange = (_, layouts) => {
// workaround for when dashboard starts at single mode and then multi is empty or carries single col data
// fixes test dashboard_spec['shows widgets with full width']
// TODO: open react-grid-layout issue
if (layouts[MULTI]) {
this.setState({ layouts });
}
// workaround for https://github.com/STRML/react-grid-layout/issues/889
// remove next line when fix lands
this.mode = document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI;
// end workaround
// don't save single column mode layout
if (this.mode === SINGLE) {
return;
}
const normalized = chain(layouts[MULTI])
.keyBy('i')
.mapValues(this.normalizeTo)
.value();
this.props.onLayoutChange(normalized);
};
onBreakpointChange = (mode) => {
this.mode = mode;
this.props.onBreakpointChange(mode === SINGLE);
};
// height updated by auto-height
onWidgetHeightUpdated = (widgetId, newHeight) => {
this.setState(({ layouts }) => {
const layout = cloneDeep(layouts[MULTI]); // must clone to allow react-grid-layout to compare prev/next state
const item = find(layout, { i: widgetId.toString() });
if (item) {
// update widget height
item.h = Math.ceil((newHeight + cfg.margins) / cfg.rowHeight);
}
return { layouts: { [MULTI]: layout } };
});
};
// height updated by manual resize
onWidgetResize = (layout, oldItem, newItem) => {
if (oldItem.h !== newItem.h) {
this.autoHeightCtrl.remove(Number(newItem.i));
}
this.autoHeightCtrl.resume();
};
normalizeTo = layout => ({
col: layout.x,
row: layout.y,
sizeX: layout.w,
sizeY: layout.h,
autoHeight: this.autoHeightCtrl.exists(layout.i),
});
render() {
const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode');
const { onRemoveWidget, dashboard, widgets } = this.props;
return (
<div className={className}>
<ResponsiveGridLayout
className={cx('layout', { 'disable-animations': this.state.disableAnimations })}
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
rowHeight={cfg.rowHeight - cfg.margins}
margin={[cfg.margins, cfg.margins]}
isDraggable={this.props.isEditing}
isResizable={this.props.isEditing}
onResizeStart={this.autoHeightCtrl.stop}
onResizeStop={this.onWidgetResize}
layouts={this.state.layouts}
onLayoutChange={this.onLayoutChange}
onBreakpointChange={this.onBreakpointChange}
breakpoints={{ [MULTI]: cfg.mobileBreakPoint, [SINGLE]: 0 }}
>
{widgets.map(widget => (
<div
key={widget.id}
data-grid={DashboardGrid.normalizeFrom(widget)}
data-widgetid={widget.id}
data-test={`WidgetId${widget.id}`}
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
>
<DashboardWidget
widget={widget}
dashboard={dashboard}
filters={this.props.filters}
deleted={() => onRemoveWidget(widget.id)}
/>
</div>
))}
</ResponsiveGridLayout>
</div>
);
}
}
export default function init(ngModule) {
ngModule.component('dashboardGrid', react2angular(DashboardGrid));
}
init.init = true;

View File

@ -0,0 +1,7 @@
.react-grid-layout {
&.disable-animations {
& > .react-grid-item {
transition: none !important;
}
}
}

View File

@ -1,87 +0,0 @@
import $ from 'jquery';
import _ from 'lodash';
import 'jquery-ui/ui/widgets/draggable';
import 'jquery-ui/ui/widgets/droppable';
import 'jquery-ui/ui/widgets/resizable';
import 'gridstack/dist/gridstack.css';
// eslint-disable-next-line import/first
import gridstack from 'gridstack';
function sequence(...fns) {
fns = _.filter(fns, _.isFunction);
if (fns.length > 0) {
return function sequenceWrapper(...args) {
for (let i = 0; i < fns.length; i += 1) {
fns[i].apply(this, args);
}
};
}
return _.noop;
}
// eslint-disable-next-line import/prefer-default-export
function JQueryUIGridStackDragDropPlugin(grid) {
gridstack.GridStackDragDropPlugin.call(this, grid);
}
gridstack.GridStackDragDropPlugin.registerPlugin(JQueryUIGridStackDragDropPlugin);
JQueryUIGridStackDragDropPlugin.prototype = Object.create(gridstack.GridStackDragDropPlugin.prototype);
JQueryUIGridStackDragDropPlugin.prototype.constructor = JQueryUIGridStackDragDropPlugin;
JQueryUIGridStackDragDropPlugin.prototype.resizable = function resizable(el, opts, key, value) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.resizable(opts);
} else if (opts === 'option') {
el.resizable(opts, key, value);
} else {
el.resizable(_.extend({}, this.grid.opts.resizable, {
// run user-defined callback before internal one
start: sequence(this.grid.opts.resizable.start, opts.start),
// this and next - run user-defined callback after internal one
stop: sequence(opts.stop, this.grid.opts.resizable.stop),
resize: sequence(opts.resize, this.grid.opts.resizable.resize),
}));
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.draggable = function draggable(el, opts) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.draggable(opts);
} else {
el.draggable(_.extend({}, this.grid.opts.draggable, {
containment: this.grid.opts.isNested ? this.grid.container.parent() : null,
// run user-defined callback before internal one
start: sequence(this.grid.opts.draggable.start, opts.start),
// this and next - run user-defined callback after internal one
stop: sequence(opts.stop, this.grid.opts.draggable.stop),
drag: sequence(opts.drag, this.grid.opts.draggable.drag),
}));
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.droppable = function droppable(el, opts) {
el = $(el);
if (opts === 'disable' || opts === 'enable') {
el.droppable(opts);
} else {
el.droppable({
accept: opts.accept,
});
}
return this;
};
JQueryUIGridStackDragDropPlugin.prototype.isDroppable = function isDroppable(el) {
return Boolean($(el).data('droppable'));
};
JQueryUIGridStackDragDropPlugin.prototype.on = function on(el, eventName, callback) {
$(el).on(eventName, callback);
return this;
};

View File

@ -1,55 +0,0 @@
.grid-stack {
// Same options as in JS
@gridstack-margin: 15px;
@gridstack-width: 6;
margin-right: -@gridstack-margin;
.gridstack-columns(@column, @total) when (@column > 0) {
@value: 100% * (@column / @total);
> .grid-stack-item[data-gs-min-width="@{column}"] { min-width: @value }
> .grid-stack-item[data-gs-max-width="@{column}"] { max-width: @value }
> .grid-stack-item[data-gs-width="@{column}"] { width: @value }
> .grid-stack-item[data-gs-x="@{column}"] { left: @value }
.gridstack-columns((@column - 1), @total); // next iteration
}
.gridstack-columns(@gridstack-width, @gridstack-width);
.grid-stack-item {
.grid-stack-item-content {
overflow: visible !important;
box-shadow: none !important;
opacity: 1 !important;
left: 0 !important;
right: @gridstack-margin !important;
}
.ui-resizable-handle {
background: none !important;
&.ui-resizable-w,
&.ui-resizable-sw {
left: 0 !important;
}
&.ui-resizable-e,
&.ui-resizable-se {
right: @gridstack-margin !important;
}
}
&.grid-stack-placeholder > .placeholder-content {
border: 0;
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
left: 0 !important;
right: @gridstack-margin !important;
}
}
&.grid-stack-one-column-mode > .grid-stack-item {
margin-bottom: @gridstack-margin !important;
}
}

View File

@ -1,400 +0,0 @@
import $ from 'jquery';
import _ from 'lodash';
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
onLayoutChanged: '=',
isOneColumnMode: '=',
},
controller() {
this.$el = null;
this.resizingWidget = null;
this.draggingWidget = null;
this.grid = () => (this.$el ? this.$el.data('gridstack') : null);
this._updateStyles = () => {
const grid = this.grid();
if (grid) {
// compute real grid height; `gridstack` sometimes uses only "dirty"
// items and computes wrong height
const gridHeight = _.chain(grid.grid.nodes)
.map(node => node.y + node.height)
.max()
.value();
// `_updateStyles` is internal, but grid sometimes "forgets"
// to rebuild stylesheet, so we need to force it
if (_.isObject(grid._styles)) {
grid._styles._max = 0; // reset size cache
}
grid._updateStyles(gridHeight + 10);
}
};
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,
);
this._updateStyles();
}
};
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.removeWidget = ($element) => {
const grid = this.grid();
if (grid) {
grid.removeWidget($element, false);
this._updateStyles();
}
};
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);
}
} finally {
grid.commit();
this._updateStyles();
}
}
};
},
link: ($scope, $element, $attr, controller) => {
const isOneColumnModeAssignable = _.isFunction($parse($attr.onLayoutChanged).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);
}
});
if ($scope.onLayoutChanged) {
$scope.onLayoutChanged();
}
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);
});
$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);
}
init.init = true;

View File

@ -1,7 +1,7 @@
<div class="widget-wrapper"> <div class="widget-wrapper">
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type" <div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
ng-switch="$ctrl.widget.getQueryResult().getStatus()"> ng-switch="$ctrl.widget.getQueryResult().getStatus()">
<div class="body-row"> <div class="body-row widget-header">
<div class="t-header widget clearfix"> <div class="t-header widget clearfix">
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"> <div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
<div class="actions"> <div class="actions">
@ -12,7 +12,7 @@
uib-dropdown dropdown-append-to-body="true" uib-dropdown dropdown-append-to-body="true"
> >
<div class="actions"> <div class="actions">
<a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more-vert"></i></a> <a data-toggle="dropdown" uib-dropdown-toggle class="p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
</div> </div>
<ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu> <ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
@ -94,12 +94,13 @@
<a class="actions" ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a> <a class="actions" ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
</div> </div>
</div> </div>
<div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()" uib-dropdown> <div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()"
uib-dropdown dropdown-append-to-body="true">
<div class="dropdown-header"> <div class="dropdown-header">
<a data-toggle="dropdown" uib-dropdown-toggle class="actions"><i class="zmdi zmdi-more"></i></a> <a data-toggle="dropdown" uib-dropdown-toggle class="actions p-l-15 p-r-15"><i class="zmdi zmdi-more-vert"></i></a>
</div> </div>
<ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000"> <ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu style="z-index:1000000">
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.editTextBox()">Edit</a></li> <li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.editTextBox()">Edit</a></li>
<li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li> <li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
</ul> </ul>

View File

@ -1,4 +1,5 @@
import { filter } from 'lodash'; import { filter } from 'lodash';
import { angular2react } from 'angular2react';
import template from './widget.html'; import template from './widget.html';
import TextboxDialog from '@/components/dashboards/TextboxDialog'; import TextboxDialog from '@/components/dashboards/TextboxDialog';
import widgetDialogTemplate from './widget-dialog.html'; import widgetDialogTemplate from './widget-dialog.html';
@ -18,6 +19,8 @@ const WidgetDialog = {
}, },
}; };
export let DashboardWidget = null; // eslint-disable-line import/no-mutable-exports
function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, $timeout, Events, currentUser) { function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, $timeout, Events, currentUser) {
this.canViewQuery = currentUser.hasPermission('view_query'); this.canViewQuery = currentUser.hasPermission('view_query');
@ -106,19 +109,24 @@ function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope,
} }
} }
const DashboardWidgetOptions = {
template,
controller: DashboardWidgetCtrl,
bindings: {
widget: '<',
public: '<',
dashboard: '<',
filters: '<',
deleted: '<',
},
};
export default function init(ngModule) { export default function init(ngModule) {
ngModule.component('widgetDialog', WidgetDialog); ngModule.component('widgetDialog', WidgetDialog);
ngModule.component('dashboardWidget', { ngModule.component('dashboardWidget', DashboardWidgetOptions);
template, ngModule.run(['$injector', ($injector) => {
controller: DashboardWidgetCtrl, DashboardWidget = angular2react('dashboardWidget ', DashboardWidgetOptions, $injector);
bindings: { }]);
widget: '<',
public: '<',
dashboard: '<',
filters: '<',
deleted: '&onDelete',
},
});
} }
init.init = true; init.init = true;

View File

@ -89,3 +89,33 @@ visualization-name {
} }
} }
} }
// react-grid-layout overrides
.react-grid-item {
// placeholder color
&.react-grid-placeholder {
border-radius: 3px;
background-color: #E0E6EB;
opacity: 0.5;
}
// resize placeholder behind widget, the lib's default is above 🤷‍♂️
&.resizing {
z-index: 3;
}
// auto-height animation
&.cssTransforms:not(.resizing) {
transition-property: transform, height; // added ", height"
}
// resize handle size
& > .react-resizable-handle::after {
width: 11px;
height: 11px;
right: 5px;
bottom: 5px;
}
}

View File

@ -1,4 +1,4 @@
const dashboardGridOptions = { export default {
columns: 6, // grid columns count columns: 6, // grid columns count
rowHeight: 50, // grid row height (incl. bottom padding) rowHeight: 50, // grid row height (incl. bottom padding)
margins: 15, // widget margins margins: 15, // widget margins
@ -11,9 +11,3 @@ const dashboardGridOptions = {
minSizeY: 1, minSizeY: 1,
maxSizeY: 1000, maxSizeY: 1000,
}; };
export default function init(ngModule) {
ngModule.constant('dashboardGridOptions', dashboardGridOptions);
}
init.init = true;

View File

@ -29,7 +29,6 @@ import * as filters from '@/filters';
import registerDirectives from '@/directives'; import registerDirectives from '@/directives';
import markdownFilter from '@/filters/markdown'; import markdownFilter from '@/filters/markdown';
import dateTimeFilter from '@/filters/datetime'; import dateTimeFilter from '@/filters/datetime';
import dashboardGridOptions from './dashboard-grid-options';
import './antd-spinner'; import './antd-spinner';
const logger = debug('redash:config'); const logger = debug('redash:config');
@ -58,8 +57,6 @@ const requirements = [
const ngModule = angular.module('app', requirements); const ngModule = angular.module('app', requirements);
dashboardGridOptions(ngModule);
function registerAll(context) { function registerAll(context) {
const modules = context const modules = context
.keys() .keys()

View File

@ -25,7 +25,7 @@
</span> </span>
<span ng-switch-default> <span ng-switch-default>
<span class="save-status" data-error>Saving Failed</span> <span class="save-status" data-error>Saving Failed</span>
<button class="btn btn-primary btn-sm" ng-click="$ctrl.saveDashboardLayout()"> <button class="btn btn-primary btn-sm" ng-click="$ctrl.retrySaveDashboardLayout()">
Retry Retry
</button> </button>
</span> </span>
@ -101,18 +101,17 @@
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters> <filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters>
</div> </div>
<div ng-if="$ctrl.dashboard.widgets.length > 0" id="dashboard-container" ng-class="{'preview-mode': !$ctrl.layoutEditing, 'editing-mode': $ctrl.layoutEditing, 'grid-enabled': !$ctrl.isGridDisabled}"> <div id="dashboard-container">
<div gridstack editing="$ctrl.layoutEditing" on-layout-changed="$ctrl.onLayoutChanged" <dashboard-grid
is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper"> ng-if="$ctrl.dashboard"
<div class="dashboard-widget-wrapper" dashboard="$ctrl.dashboard"
ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id" widgets="$ctrl.dashboard.widgets"
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}" data-test="WidgetId{{ widget.id }}"> filters="$ctrl.filters"
<div class="grid-stack-item-content"> is-editing="$ctrl.layoutEditing && !$ctrl.isGridDisabled"
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" filters="$ctrl.filters" on-layout-change="$ctrl.onLayoutChange"
on-delete="$ctrl.removeWidget(widget.id)"></dashboard-widget> on-breakpoint-change="$ctrl.onBreakpointChanged"
</div> on-remove-widget="$ctrl.removeWidget"
</div> />
</div>
</div> </div>
<div class="add-widget-container" ng-if="$ctrl.layoutEditing"> <div class="add-widget-container" ng-if="$ctrl.layoutEditing">

View File

@ -17,19 +17,11 @@ import notification from '@/services/notification';
import './dashboard.less'; import './dashboard.less';
function isWidgetPositionChanged(oldPosition, newPosition) { function getChangedPositions(widgets, nextPositions = {}) {
const fields = ['col', 'row', 'sizeX', 'sizeY', 'autoHeight']; return _.pickBy(nextPositions, (nextPos, widgetId) => {
oldPosition = _.pick(oldPosition, fields); const widget = _.find(widgets, { id: Number(widgetId) });
newPosition = _.pick(newPosition, fields); const prevPos = widget.options.position;
return !!_.find(fields, key => newPosition[key] !== oldPosition[key]); return !_.isMatch(prevPos, nextPos);
}
function getWidgetsWithChangedPositions(widgets) {
return _.filter(widgets, (widget) => {
if (!_.isObject(widget.$originalPosition)) {
return true;
}
return isWidgetPositionChanged(widget.$originalPosition, widget.options.position);
}); });
} }
@ -47,28 +39,29 @@ function DashboardCtrl(
clientConfig, clientConfig,
Events, Events,
) { ) {
this.saveInProgress = false; let recentPositions = [];
this.saveDelay = false;
this.saveDashboardLayout = () => { const saveDashboardLayout = (changedPositions) => {
if (!this.dashboard.canEdit()) { if (!this.dashboard.canEdit()) {
return; return;
} }
this.isLayoutDirty = true;
// calc diff, bail if none
const changedWidgets = getWidgetsWithChangedPositions(this.dashboard.widgets);
if (!changedWidgets.length) {
this.isLayoutDirty = false;
$scope.$applyAsync();
return;
}
this.saveDelay = false;
this.saveInProgress = true; this.saveInProgress = true;
const saveChangedWidgets = _.map(changedPositions, (position, id) => {
// find widget
const widget = _.find(this.dashboard.widgets, { id: Number(id) });
// skip already deleted widget
if (!widget) {
return Promise.resolve();
}
return widget.save('options', { position });
});
return $q return $q
.all(_.map(changedWidgets, widget => widget.save())) .all(saveChangedWidgets)
.then(() => { .then(() => {
this.isLayoutDirty = false; this.isLayoutDirty = false;
if (this.editBtnClickedWhileSaving) { if (this.editBtnClickedWhileSaving) {
@ -76,32 +69,42 @@ function DashboardCtrl(
} }
}) })
.catch(() => { .catch(() => {
// 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.'); notification.error('Error saving changes.');
}) })
.finally(() => { .finally(() => {
this.saveInProgress = false; this.saveInProgress = false;
this.editBtnClickedWhileSaving = false; this.editBtnClickedWhileSaving = false;
$scope.$applyAsync();
}); });
}; };
const saveDashboardLayoutDebounced = () => { const saveDashboardLayoutDebounced = (...args) => {
this.saveDelay = true; this.saveDelay = true;
return _.debounce(() => this.saveDashboardLayout(), 2000)(); return _.debounce(() => {
this.saveDelay = false;
saveDashboardLayout(...args);
}, 2000)();
}; };
this.retrySaveDashboardLayout = () => {
this.onLayoutChange(recentPositions);
};
// grid vars
this.saveDelay = false; this.saveDelay = false;
this.saveInProgress = false;
this.recentLayoutPositions = {};
this.editBtnClickedWhileSaving = false; this.editBtnClickedWhileSaving = false;
this.layoutEditing = false; this.layoutEditing = false;
this.isLayoutDirty = false;
this.isGridDisabled = false;
// dashboard vars
this.isFullscreen = false; this.isFullscreen = false;
this.refreshRate = null; this.refreshRate = null;
this.isGridDisabled = false;
this.updateGridItems = null;
this.showPermissionsControl = clientConfig.showPermissionsControl; this.showPermissionsControl = clientConfig.showPermissionsControl;
this.globalParameters = []; this.globalParameters = [];
this.isDashboardOwner = false; this.isDashboardOwner = false;
this.isLayoutDirty = false;
this.filters = []; this.filters = [];
this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({ this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
@ -233,17 +236,35 @@ function DashboardCtrl(
}); });
}; };
this.onLayoutChanged = () => { this.onLayoutChange = (positions) => {
// prevent unnecessary save when gridstack is loaded recentPositions = positions; // required for retry if subsequent save fails
if (!this.layoutEditing) {
// determine position changes
const changedPositions = getChangedPositions(this.dashboard.widgets, positions);
if (_.isEmpty(changedPositions)) {
this.isLayoutDirty = false;
$scope.$applyAsync();
return; return;
} }
this.isLayoutDirty = true; this.isLayoutDirty = true;
saveDashboardLayoutDebounced(); $scope.$applyAsync();
// debounce in edit mode, immediate in preview
if (this.layoutEditing) {
saveDashboardLayoutDebounced(changedPositions);
} else {
saveDashboardLayout(changedPositions);
}
}; };
this.editLayout = (enableEditing) => { this.onBreakpointChanged = (isSingleCol) => {
this.layoutEditing = enableEditing; this.isGridDisabled = isSingleCol;
$scope.$applyAsync();
};
this.editLayout = (isEditing) => {
this.layoutEditing = isEditing;
}; };
this.loadTags = () => getTags('api/dashboards/tags').then(tags => _.map(tags, t => t.name)); this.loadTags = () => getTags('api/dashboards/tags').then(tags => _.map(tags, t => t.name));
@ -332,6 +353,7 @@ function DashboardCtrl(
return widget.save() return widget.save()
.then(() => { .then(() => {
this.dashboard.widgets.push(widget); this.dashboard.widgets.push(widget);
this.dashboard.widgets = [...this.dashboard.widgets]; // ANGULAR_REMOVE_ME
this.onWidgetAdded(); this.onWidgetAdded();
}); });
}; };
@ -368,6 +390,7 @@ function DashboardCtrl(
return Promise.all(widgetsToSave.map(w => w.save())) return Promise.all(widgetsToSave.map(w => w.save()))
.then(() => { .then(() => {
this.dashboard.widgets.push(widget); this.dashboard.widgets.push(widget);
this.dashboard.widgets = [...this.dashboard.widgets]; // ANGULAR_REMOVE_ME
this.onWidgetAdded(); this.onWidgetAdded();
}); });
}; };
@ -388,11 +411,6 @@ function DashboardCtrl(
this.extractGlobalParameters(); this.extractGlobalParameters();
collectFilters(this.dashboard, false); collectFilters(this.dashboard, false);
$scope.$applyAsync(); $scope.$applyAsync();
if (!this.layoutEditing) {
// We need to wait a bit while `angular` updates widgets, and only then save new layout
$timeout(() => this.saveDashboardLayout(), 50);
}
}; };
this.toggleFullscreen = () => { this.toggleFullscreen = () => {

View File

@ -1,4 +1,11 @@
.dashboard-wrapper { .dashboard-wrapper {
flex-grow: 1;
margin-bottom: 85px;
.layout {
margin: -15px -15px 0;
}
.tile { .tile {
display: flex; display: flex;
position: absolute; position: absolute;
@ -17,7 +24,7 @@
overflow: visible; overflow: visible;
} }
.preview-mode & { &.preview-mode {
.widget-menu-regular { .widget-menu-regular {
display: block; display: block;
} }
@ -26,7 +33,25 @@
} }
} }
.editing-mode & { &.editing-mode {
/* Y axis lines */
background: linear-gradient(to right, transparent, transparent 1px, #F6F8F9 1px, #F6F8F9), linear-gradient(to bottom, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
background-size: 5px 50px;
background-position-y: -8px;
/* X axis lines */
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 85px;
right: 15px;
background: linear-gradient(to bottom, transparent, transparent 2px, #F6F8F9 2px, #F6F8F9 5px), linear-gradient(to left, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
background-size: calc((100vw - 15px) / 6) 5px;
background-position: -7px 1px;
}
.widget-menu-regular { .widget-menu-regular {
display: none; display: none;
} }
@ -179,7 +204,7 @@ public-dashboard-page {
#footer { #footer {
height: 95px; height: 95px;
text-align: center; text-align: center;
} }
} }
@ -198,30 +223,12 @@ dashboard-page, dashboard-page .container {
#dashboard-container { #dashboard-container {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
margin-bottom: 50px; // but not ALL the way ಠ_ಠ display: flex;
&.editing-mode.grid-enabled {
/* Y axis lines */
background: linear-gradient(to right, transparent, transparent 1px, #F6F8F9 1px, #F6F8F9), linear-gradient(to bottom, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
background-size: 5px 50px;
background-position-y: -8px;
/* X axis lines */
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 15px;
background: linear-gradient(to bottom, transparent, transparent 2px, #F6F8F9 2px, #F6F8F9 5px), linear-gradient(to left, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
background-size: calc((100vw - 15px) / 6) 5px;
background-position: -7px 1px;
}
}
} }
// placeholder bg color // soon deprecated
.grid-stack-placeholder > .placeholder-content { dashboard-grid {
background-color: rgba(224, 230, 235, 0.5) !important; flex-grow: 1;
display: flex;
flex-direction: column;
} }

View File

@ -5,16 +5,14 @@
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters> <filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters>
</div> </div>
<div style="padding-bottom: 5px" ng-if="$ctrl.dashboard.widgets.length > 0"> <div id="dashboard-container">
<div gridstack editing="false" class="dashboard-wrapper preview-mode"> <dashboard-grid
<div class="dashboard-widget-wrapper" ng-if="$ctrl.dashboard"
ng-repeat="widget in $ctrl.dashboard.widgets" dashboard="$ctrl.dashboard"
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}"> widgets="$ctrl.dashboard.widgets"
<div class="grid-stack-item-content"> filters="$ctrl.filters"
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" filters="$ctrl.filters" public="true"></dashboard-widget> is-editing="false"
</div> />
</div>
</div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import logoUrl from '@/assets/images/redash_icon_small.png'; import logoUrl from '@/assets/images/redash_icon_small.png';
import template from './public-dashboard-page.html'; import template from './public-dashboard-page.html';
import dashboardGridOptions from '@/config/dashboard-grid-options';
import './dashboard.less'; import './dashboard.less';
function loadDashboard($http, $route) { function loadDashboard($http, $route) {
@ -12,9 +13,11 @@ const PublicDashboardPage = {
bindings: { bindings: {
dashboard: '<', dashboard: '<',
}, },
controller($scope, $timeout, $location, $http, $route, dashboardGridOptions, Dashboard) { controller($scope, $timeout, $location, $http, $route, Dashboard) {
'ngInject'; 'ngInject';
this.filters = [];
this.dashboardGridOptions = Object.assign({}, dashboardGridOptions, { this.dashboardGridOptions = Object.assign({}, dashboardGridOptions, {
resizable: { enabled: false }, resizable: { enabled: false },
draggable: { enabled: false }, draggable: { enabled: false },

View File

@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import dashboardGridOptions from '@/config/dashboard-grid-options';
export let Dashboard = null; // eslint-disable-line import/no-mutable-exports export let Dashboard = null; // eslint-disable-line import/no-mutable-exports
@ -75,7 +76,7 @@ function prepareWidgetsForDashboard(widgets) {
return widgets; return widgets;
} }
function DashboardService($resource, $http, $location, currentUser, Widget, dashboardGridOptions) { function DashboardService($resource, $http, $location, currentUser, Widget) {
function prepareDashboardWidgets(widgets) { function prepareDashboardWidgets(widgets) {
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
} }

View File

@ -1,10 +1,11 @@
import moment from 'moment'; import moment from 'moment';
import { each, pick, extend, isObject, truncate, keys, difference, filter, map } from 'lodash'; import { each, pick, extend, isObject, truncate, keys, difference, filter, map, merge } from 'lodash';
import dashboardGridOptions from '@/config/dashboard-grid-options';
import { registeredVisualizations } from '@/visualizations'; import { registeredVisualizations } from '@/visualizations';
export let Widget = null; // eslint-disable-line import/no-mutable-exports export let Widget = null; // eslint-disable-line import/no-mutable-exports
function calculatePositionOptions(dashboardGridOptions, widget) { function calculatePositionOptions(widget) {
widget.width = 1; // Backward compatibility, user on back-end widget.width = 1; // Backward compatibility, user on back-end
const visualizationOptions = { const visualizationOptions = {
@ -68,7 +69,7 @@ export const ParameterMappingType = {
StaticValue: 'static-value', StaticValue: 'static-value',
}; };
function WidgetFactory($http, $location, Query, dashboardGridOptions) { function WidgetFactory($http, $location, Query) {
class WidgetService { class WidgetService {
static MappingType = ParameterMappingType; static MappingType = ParameterMappingType;
@ -78,7 +79,7 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
this[k] = v; this[k] = v;
}); });
const visualizationOptions = calculatePositionOptions(dashboardGridOptions, this); const visualizationOptions = calculatePositionOptions(this);
this.options = this.options || {}; this.options = this.options || {};
this.options.position = extend( this.options.position = extend(
@ -90,13 +91,6 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
if (this.options.position.sizeY < 0) { if (this.options.position.sizeY < 0) {
this.options.position.autoHeight = true; this.options.position.autoHeight = true;
} }
this.updateOriginalPosition();
}
updateOriginalPosition() {
// Save original position (create a shallow copy)
this.$originalPosition = extend({}, this.options.position);
} }
getQuery() { getQuery() {
@ -151,8 +145,11 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
return this.queryResult.toPromise(); return this.queryResult.toPromise();
} }
save() { save(key, value) {
const data = pick(this, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id'); const data = pick(this, 'options', 'text', 'id', 'width', 'dashboard_id', 'visualization_id');
if (key && value) {
data[key] = merge({}, data[key], value); // done like this so `this.options` doesn't get updated by side-effect
}
let url = 'api/widgets'; let url = 'api/widgets';
if (this.id) { if (this.id) {
@ -164,8 +161,6 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
this[k] = v; this[k] = v;
}); });
this.updateOriginalPosition();
return this; return this;
}); });
} }

View File

@ -4,7 +4,7 @@ import { createDashboard, createQuery, addTextbox, addWidget } from '../../suppo
const { get } = Cypress._; const { get } = Cypress._;
const RESIZE_HANDLE_SELECTOR = '.ui-resizable-se'; const RESIZE_HANDLE_SELECTOR = '.react-resizable-handle';
function getWidgetTestId(widget) { function getWidgetTestId(widget) {
@ -31,17 +31,19 @@ function editDashboard() {
}); });
} }
function dragBy(wrapper, offsetLeft = 0, offsetTop = 0, force = false) { function dragBy(wrapper, offsetLeft, offsetTop, force = false) {
let start; if (!offsetLeft) {
offsetLeft = 1;
}
if (!offsetTop) {
offsetTop = 1;
}
return wrapper return wrapper
.then(($el) => { .trigger('mouseover', { force })
start = $el.offset(); .trigger('mousedown', 'topLeft', { force })
return wrapper .trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange
.trigger('mouseover', { force }) .trigger('mousemove', offsetLeft, offsetTop, { force })
.trigger('mousedown', { pageX: start.left, pageY: start.top, which: 1, force }) .trigger('mouseup', { force });
.trigger('mousemove', { pageX: start.left + offsetLeft, pageY: start.top + offsetTop, which: 1, force })
.trigger('mouseup', { force });
});
} }
function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) { function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) {
@ -157,8 +159,7 @@ describe('Dashboard', () => {
}); });
}); });
// eslint-disable-next-line jest/no-disabled-tests it('allows opening menu after removal', function () {
it.skip('allows opening menu after removal', function () {
let elTestId1; let elTestId1;
addTextbox(this.dashboardId, 'txb 1') addTextbox(this.dashboardId, 'txb 1')
.then(getWidgetTestId) .then(getWidgetTestId)
@ -235,7 +236,7 @@ describe('Dashboard', () => {
const { top, left } = $el.offset(); const { top, left } = $el.offset();
expect(top).to.eq(214); expect(top).to.eq(214);
expect(left).to.eq(215); expect(left).to.eq(215);
expect($el.width()).to.eq(600); expect($el.width()).to.eq(585);
expect($el.height()).to.eq(185); expect($el.height()).to.eq(185);
}); });
}); });
@ -300,21 +301,21 @@ describe('Dashboard', () => {
resizeBy(cy.get('@textboxEl'), 90) resizeBy(cy.get('@textboxEl'), 90)
.then(() => cy.get('@textboxEl')) .then(() => cy.get('@textboxEl'))
.invoke('width') .invoke('width')
.should('eq', 600); // no change, 600 -> 600 .should('eq', 585); // no change, 585 -> 585
}); });
it('moves one column when dragged over snap threshold', () => { it('moves one column when dragged over snap threshold', () => {
resizeBy(cy.get('@textboxEl'), 110) resizeBy(cy.get('@textboxEl'), 110)
.then(() => cy.get('@textboxEl')) .then(() => cy.get('@textboxEl'))
.invoke('width') .invoke('width')
.should('eq', 800); // resized by 200, 600 -> 800 .should('eq', 785); // resized by 200, 585 -> 785
}); });
it('moves two columns when dragged over snap threshold', () => { it('moves two columns when dragged over snap threshold', () => {
resizeBy(cy.get('@textboxEl'), 400) resizeBy(cy.get('@textboxEl'), 400)
.then(() => cy.get('@textboxEl')) .then(() => cy.get('@textboxEl'))
.invoke('width') .invoke('width')
.should('eq', 1000); // resized by 400, 600 -> 1000 .should('eq', 985); // resized by 400, 585 -> 985
}); });
}); });
@ -342,7 +343,7 @@ describe('Dashboard', () => {
.then($el => resizeBy(cy.get('@textboxEl'), -$el.width(), -$el.height())) // resize to 0,0 .then($el => resizeBy(cy.get('@textboxEl'), -$el.width(), -$el.height())) // resize to 0,0
.then(() => cy.get('@textboxEl')) .then(() => cy.get('@textboxEl'))
.should(($el) => { .should(($el) => {
expect($el.width()).to.eq(200); // min textbox width expect($el.width()).to.eq(185); // min textbox width
expect($el.height()).to.eq(35); // min textbox height expect($el.height()).to.eq(35); // min textbox height
}); });
}); });
@ -433,7 +434,7 @@ describe('Dashboard', () => {
cy.getByTestId('RefreshIndicator').as('refreshButton'); cy.getByTestId('RefreshIndicator').as('refreshButton');
}); });
cy.getByTestId(`ParameterName${paramName}`).within(() => { cy.getByTestId(`ParameterName${paramName}`).within(() => {
cy.get('input').as('paramInput'); cy.getByTestId('TextParamInput').as('paramInput');
}); });
}); });
}); });
@ -469,8 +470,10 @@ describe('Dashboard', () => {
cy.get('@widget').invoke('height').should('eq', 285); cy.get('@widget').invoke('height').should('eq', 285);
// resize height by 1 grid row // resize height by 1 grid row
resizeBy(cy.get('@widget'), 0, 5); resizeBy(cy.get('@widget'), 0, 50)
cy.get('@widget').invoke('height').should('eq', 335); .then(() => cy.get('@widget'))
.invoke('height')
.should('eq', 335); // resized by 50, , 135 -> 185
// add 4 table rows // add 4 table rows
cy.get('@paramInput').clear().type('5{enter}'); cy.get('@paramInput').clear().type('5{enter}');
@ -505,12 +508,12 @@ describe('Dashboard', () => {
it('shows widgets with full width', () => { it('shows widgets with full width', () => {
cy.get('@textboxEl').should(($el) => { cy.get('@textboxEl').should(($el) => {
expect($el.width()).to.eq(785); expect($el.width()).to.eq(770);
}); });
cy.viewport(801, 800); cy.viewport(801, 800);
cy.get('@textboxEl').should(($el) => { cy.get('@textboxEl').should(($el) => {
expect($el.width()).to.eq(393); expect($el.width()).to.eq(378);
}); });
}); });

39
package-lock.json generated
View File

@ -8912,16 +8912,6 @@
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==" "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA=="
}, },
"gridstack": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/gridstack/-/gridstack-0.3.0.tgz",
"integrity": "sha1-vhx4kfP70q9g+dYPTH1RejDTu3g=",
"requires": {
"jquery": "^3.1.0",
"jquery-ui": "^1.12.0",
"lodash": "^4.14.2"
}
},
"growly": { "growly": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
@ -14866,6 +14856,26 @@
"scheduler": "^0.13.3" "scheduler": "^0.13.3"
} }
}, },
"react-draggable": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.3.0.tgz",
"integrity": "sha512-U7/jD0tAW4T0S7DCPK0kkKLyL0z61sC/eqU+NUfDjnq+JtBKaYKDHpsK2wazctiA4alEzCXUnzkREoxppOySVw==",
"requires": {
"classnames": "^2.2.5",
"prop-types": "^15.6.0"
}
},
"react-grid-layout": {
"version": "git+https://github.com/getredash/react-grid-layout.git#45c44030b8501aa14dae746c5afa3e867d86ebea",
"from": "git+https://github.com/getredash/react-grid-layout.git",
"requires": {
"classnames": "2.x",
"lodash.isequal": "^4.0.0",
"prop-types": "15.x",
"react-draggable": "3.x",
"react-resizable": "1.x"
}
},
"react-is": { "react-is": {
"version": "16.8.3", "version": "16.8.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.3.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.3.tgz",
@ -14887,6 +14897,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
}, },
"react-resizable": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-1.7.5.tgz",
"integrity": "sha512-lauPcBsLqmxMHXHpTeOBpYenGalbSikYr8hK+lwtNYMQX1pGd2iYE+pDvZEV97nCnzuCtWM9htp7OpsBIY2Sjw==",
"requires": {
"prop-types": "15.x",
"react-draggable": "^2.2.6 || ^3.0.3"
}
},
"react-slick": { "react-slick": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.23.2.tgz", "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.23.2.tgz",

View File

@ -59,7 +59,6 @@
"d3-cloud": "^1.2.4", "d3-cloud": "^1.2.4",
"debug": "^3.1.0", "debug": "^3.1.0",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"gridstack": "^0.3.0",
"hoist-non-react-statics": "^3.3.0", "hoist-non-react-statics": "^3.3.0",
"jquery": "^3.2.1", "jquery": "^3.2.1",
"jquery-ui": "^1.12.1", "jquery-ui": "^1.12.1",
@ -81,6 +80,7 @@
"react": "^16.8.3", "react": "^16.8.3",
"react-ace": "^6.1.0", "react-ace": "^6.1.0",
"react-dom": "^16.8.3", "react-dom": "^16.8.3",
"react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git",
"react2angular": "^3.2.1", "react2angular": "^3.2.1",
"ui-select": "^0.19.8", "ui-select": "^0.19.8",
"underscore.string": "^3.3.4" "underscore.string": "^3.3.4"