mirror of
https://github.com/valitydev/redash.git
synced 2024-11-06 17:15:17 +00:00
* 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:
parent
4508975749
commit
606cf12e74
110
client/app/components/dashboards/AutoHeightController.js
Normal file
110
client/app/components/dashboards/AutoHeightController.js
Normal 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;
|
||||
}
|
||||
}
|
213
client/app/components/dashboards/DashboardGrid.jsx
Normal file
213
client/app/components/dashboards/DashboardGrid.jsx
Normal 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;
|
7
client/app/components/dashboards/dashboard-grid.less
Normal file
7
client/app/components/dashboards/dashboard-grid.less
Normal file
@ -0,0 +1,7 @@
|
||||
.react-grid-layout {
|
||||
&.disable-animations {
|
||||
& > .react-grid-item {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
@ -1,7 +1,7 @@
|
||||
<div class="widget-wrapper">
|
||||
<div class="tile body-container widget-visualization" ng-if="$ctrl.type=='visualization'" ng-class="$ctrl.type"
|
||||
ng-switch="$ctrl.widget.getQueryResult().getStatus()">
|
||||
<div class="body-row">
|
||||
<div class="body-row widget-header">
|
||||
<div class="t-header widget clearfix">
|
||||
<div class="dropdown pull-right widget-menu-remove" ng-if="!$ctrl.public && $ctrl.dashboard.canEdit()">
|
||||
<div class="actions">
|
||||
@ -12,7 +12,7 @@
|
||||
uib-dropdown dropdown-append-to-body="true"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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.deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { filter } from 'lodash';
|
||||
import { angular2react } from 'angular2react';
|
||||
import template from './widget.html';
|
||||
import TextboxDialog from '@/components/dashboards/TextboxDialog';
|
||||
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) {
|
||||
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) {
|
||||
ngModule.component('widgetDialog', WidgetDialog);
|
||||
ngModule.component('dashboardWidget', {
|
||||
template,
|
||||
controller: DashboardWidgetCtrl,
|
||||
bindings: {
|
||||
widget: '<',
|
||||
public: '<',
|
||||
dashboard: '<',
|
||||
filters: '<',
|
||||
deleted: '&onDelete',
|
||||
},
|
||||
});
|
||||
ngModule.component('dashboardWidget', DashboardWidgetOptions);
|
||||
ngModule.run(['$injector', ($injector) => {
|
||||
DashboardWidget = angular2react('dashboardWidget ', DashboardWidgetOptions, $injector);
|
||||
}]);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
const dashboardGridOptions = {
|
||||
export default {
|
||||
columns: 6, // grid columns count
|
||||
rowHeight: 50, // grid row height (incl. bottom padding)
|
||||
margins: 15, // widget margins
|
||||
@ -11,9 +11,3 @@ const dashboardGridOptions = {
|
||||
minSizeY: 1,
|
||||
maxSizeY: 1000,
|
||||
};
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.constant('dashboardGridOptions', dashboardGridOptions);
|
||||
}
|
||||
|
||||
init.init = true;
|
||||
|
@ -29,7 +29,6 @@ import * as filters from '@/filters';
|
||||
import registerDirectives from '@/directives';
|
||||
import markdownFilter from '@/filters/markdown';
|
||||
import dateTimeFilter from '@/filters/datetime';
|
||||
import dashboardGridOptions from './dashboard-grid-options';
|
||||
import './antd-spinner';
|
||||
|
||||
const logger = debug('redash:config');
|
||||
@ -58,8 +57,6 @@ const requirements = [
|
||||
|
||||
const ngModule = angular.module('app', requirements);
|
||||
|
||||
dashboardGridOptions(ngModule);
|
||||
|
||||
function registerAll(context) {
|
||||
const modules = context
|
||||
.keys()
|
||||
|
@ -25,7 +25,7 @@
|
||||
</span>
|
||||
<span ng-switch-default>
|
||||
<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
|
||||
</button>
|
||||
</span>
|
||||
@ -101,18 +101,17 @@
|
||||
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters>
|
||||
</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 gridstack editing="$ctrl.layoutEditing" on-layout-changed="$ctrl.onLayoutChanged"
|
||||
is-one-column-mode="$ctrl.isGridDisabled" class="dashboard-wrapper">
|
||||
<div class="dashboard-widget-wrapper"
|
||||
ng-repeat="widget in $ctrl.dashboard.widgets track by widget.id"
|
||||
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}" data-test="WidgetId{{ widget.id }}">
|
||||
<div class="grid-stack-item-content">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" filters="$ctrl.filters"
|
||||
on-delete="$ctrl.removeWidget(widget.id)"></dashboard-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-container">
|
||||
<dashboard-grid
|
||||
ng-if="$ctrl.dashboard"
|
||||
dashboard="$ctrl.dashboard"
|
||||
widgets="$ctrl.dashboard.widgets"
|
||||
filters="$ctrl.filters"
|
||||
is-editing="$ctrl.layoutEditing && !$ctrl.isGridDisabled"
|
||||
on-layout-change="$ctrl.onLayoutChange"
|
||||
on-breakpoint-change="$ctrl.onBreakpointChanged"
|
||||
on-remove-widget="$ctrl.removeWidget"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="add-widget-container" ng-if="$ctrl.layoutEditing">
|
||||
|
@ -17,19 +17,11 @@ import notification from '@/services/notification';
|
||||
|
||||
import './dashboard.less';
|
||||
|
||||
function isWidgetPositionChanged(oldPosition, newPosition) {
|
||||
const fields = ['col', 'row', 'sizeX', 'sizeY', 'autoHeight'];
|
||||
oldPosition = _.pick(oldPosition, fields);
|
||||
newPosition = _.pick(newPosition, fields);
|
||||
return !!_.find(fields, key => newPosition[key] !== oldPosition[key]);
|
||||
}
|
||||
|
||||
function getWidgetsWithChangedPositions(widgets) {
|
||||
return _.filter(widgets, (widget) => {
|
||||
if (!_.isObject(widget.$originalPosition)) {
|
||||
return true;
|
||||
}
|
||||
return isWidgetPositionChanged(widget.$originalPosition, widget.options.position);
|
||||
function getChangedPositions(widgets, nextPositions = {}) {
|
||||
return _.pickBy(nextPositions, (nextPos, widgetId) => {
|
||||
const widget = _.find(widgets, { id: Number(widgetId) });
|
||||
const prevPos = widget.options.position;
|
||||
return !_.isMatch(prevPos, nextPos);
|
||||
});
|
||||
}
|
||||
|
||||
@ -47,28 +39,29 @@ function DashboardCtrl(
|
||||
clientConfig,
|
||||
Events,
|
||||
) {
|
||||
this.saveInProgress = false;
|
||||
this.saveDelay = false;
|
||||
let recentPositions = [];
|
||||
|
||||
this.saveDashboardLayout = () => {
|
||||
const saveDashboardLayout = (changedPositions) => {
|
||||
if (!this.dashboard.canEdit()) {
|
||||
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;
|
||||
|
||||
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
|
||||
.all(_.map(changedWidgets, widget => widget.save()))
|
||||
.all(saveChangedWidgets)
|
||||
.then(() => {
|
||||
this.isLayoutDirty = false;
|
||||
if (this.editBtnClickedWhileSaving) {
|
||||
@ -76,32 +69,42 @@ function DashboardCtrl(
|
||||
}
|
||||
})
|
||||
.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.');
|
||||
})
|
||||
.finally(() => {
|
||||
this.saveInProgress = false;
|
||||
this.editBtnClickedWhileSaving = false;
|
||||
$scope.$applyAsync();
|
||||
});
|
||||
};
|
||||
|
||||
const saveDashboardLayoutDebounced = () => {
|
||||
const saveDashboardLayoutDebounced = (...args) => {
|
||||
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.saveInProgress = false;
|
||||
this.recentLayoutPositions = {};
|
||||
this.editBtnClickedWhileSaving = false;
|
||||
this.layoutEditing = false;
|
||||
this.isLayoutDirty = false;
|
||||
this.isGridDisabled = false;
|
||||
|
||||
// dashboard vars
|
||||
this.isFullscreen = false;
|
||||
this.refreshRate = null;
|
||||
this.isGridDisabled = false;
|
||||
this.updateGridItems = null;
|
||||
this.showPermissionsControl = clientConfig.showPermissionsControl;
|
||||
this.globalParameters = [];
|
||||
this.isDashboardOwner = false;
|
||||
this.isLayoutDirty = false;
|
||||
this.filters = [];
|
||||
|
||||
this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({
|
||||
@ -233,17 +236,35 @@ function DashboardCtrl(
|
||||
});
|
||||
};
|
||||
|
||||
this.onLayoutChanged = () => {
|
||||
// prevent unnecessary save when gridstack is loaded
|
||||
if (!this.layoutEditing) {
|
||||
this.onLayoutChange = (positions) => {
|
||||
recentPositions = positions; // required for retry if subsequent save fails
|
||||
|
||||
// determine position changes
|
||||
const changedPositions = getChangedPositions(this.dashboard.widgets, positions);
|
||||
if (_.isEmpty(changedPositions)) {
|
||||
this.isLayoutDirty = false;
|
||||
$scope.$applyAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
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.layoutEditing = enableEditing;
|
||||
this.onBreakpointChanged = (isSingleCol) => {
|
||||
this.isGridDisabled = isSingleCol;
|
||||
$scope.$applyAsync();
|
||||
};
|
||||
|
||||
this.editLayout = (isEditing) => {
|
||||
this.layoutEditing = isEditing;
|
||||
};
|
||||
|
||||
this.loadTags = () => getTags('api/dashboards/tags').then(tags => _.map(tags, t => t.name));
|
||||
@ -332,6 +353,7 @@ function DashboardCtrl(
|
||||
return widget.save()
|
||||
.then(() => {
|
||||
this.dashboard.widgets.push(widget);
|
||||
this.dashboard.widgets = [...this.dashboard.widgets]; // ANGULAR_REMOVE_ME
|
||||
this.onWidgetAdded();
|
||||
});
|
||||
};
|
||||
@ -368,6 +390,7 @@ function DashboardCtrl(
|
||||
return Promise.all(widgetsToSave.map(w => w.save()))
|
||||
.then(() => {
|
||||
this.dashboard.widgets.push(widget);
|
||||
this.dashboard.widgets = [...this.dashboard.widgets]; // ANGULAR_REMOVE_ME
|
||||
this.onWidgetAdded();
|
||||
});
|
||||
};
|
||||
@ -388,11 +411,6 @@ function DashboardCtrl(
|
||||
this.extractGlobalParameters();
|
||||
collectFilters(this.dashboard, false);
|
||||
$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 = () => {
|
||||
|
@ -1,4 +1,11 @@
|
||||
.dashboard-wrapper {
|
||||
flex-grow: 1;
|
||||
margin-bottom: 85px;
|
||||
|
||||
.layout {
|
||||
margin: -15px -15px 0;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
@ -17,7 +24,7 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.preview-mode & {
|
||||
&.preview-mode {
|
||||
.widget-menu-regular {
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
@ -179,7 +204,7 @@ public-dashboard-page {
|
||||
|
||||
#footer {
|
||||
height: 95px;
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@ -198,30 +223,12 @@ dashboard-page, dashboard-page .container {
|
||||
#dashboard-container {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
margin-bottom: 50px; // but not ALL the way ಠ_ಠ
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// placeholder bg color
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
background-color: rgba(224, 230, 235, 0.5) !important;
|
||||
// soon deprecated
|
||||
dashboard-grid {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -5,16 +5,14 @@
|
||||
<filters filters="$ctrl.filters" on-change="$ctrl.filtersOnChange"></filters>
|
||||
</div>
|
||||
|
||||
<div style="padding-bottom: 5px" ng-if="$ctrl.dashboard.widgets.length > 0">
|
||||
<div gridstack editing="false" class="dashboard-wrapper preview-mode">
|
||||
<div class="dashboard-widget-wrapper"
|
||||
ng-repeat="widget in $ctrl.dashboard.widgets"
|
||||
gridstack-item="widget.options.position" gridstack-item-id="{{ widget.id }}">
|
||||
<div class="grid-stack-item-content">
|
||||
<dashboard-widget widget="widget" dashboard="$ctrl.dashboard" filters="$ctrl.filters" public="true"></dashboard-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dashboard-container">
|
||||
<dashboard-grid
|
||||
ng-if="$ctrl.dashboard"
|
||||
dashboard="$ctrl.dashboard"
|
||||
widgets="$ctrl.dashboard.widgets"
|
||||
filters="$ctrl.filters"
|
||||
is-editing="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import logoUrl from '@/assets/images/redash_icon_small.png';
|
||||
import template from './public-dashboard-page.html';
|
||||
import dashboardGridOptions from '@/config/dashboard-grid-options';
|
||||
import './dashboard.less';
|
||||
|
||||
function loadDashboard($http, $route) {
|
||||
@ -12,9 +13,11 @@ const PublicDashboardPage = {
|
||||
bindings: {
|
||||
dashboard: '<',
|
||||
},
|
||||
controller($scope, $timeout, $location, $http, $route, dashboardGridOptions, Dashboard) {
|
||||
controller($scope, $timeout, $location, $http, $route, Dashboard) {
|
||||
'ngInject';
|
||||
|
||||
this.filters = [];
|
||||
|
||||
this.dashboardGridOptions = Object.assign({}, dashboardGridOptions, {
|
||||
resizable: { enabled: false },
|
||||
draggable: { enabled: false },
|
||||
|
@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import dashboardGridOptions from '@/config/dashboard-grid-options';
|
||||
|
||||
export let Dashboard = null; // eslint-disable-line import/no-mutable-exports
|
||||
|
||||
@ -75,7 +76,7 @@ function prepareWidgetsForDashboard(widgets) {
|
||||
return widgets;
|
||||
}
|
||||
|
||||
function DashboardService($resource, $http, $location, currentUser, Widget, dashboardGridOptions) {
|
||||
function DashboardService($resource, $http, $location, currentUser, Widget) {
|
||||
function prepareDashboardWidgets(widgets) {
|
||||
return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
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';
|
||||
|
||||
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
|
||||
|
||||
const visualizationOptions = {
|
||||
@ -68,7 +69,7 @@ export const ParameterMappingType = {
|
||||
StaticValue: 'static-value',
|
||||
};
|
||||
|
||||
function WidgetFactory($http, $location, Query, dashboardGridOptions) {
|
||||
function WidgetFactory($http, $location, Query) {
|
||||
class WidgetService {
|
||||
static MappingType = ParameterMappingType;
|
||||
|
||||
@ -78,7 +79,7 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
|
||||
this[k] = v;
|
||||
});
|
||||
|
||||
const visualizationOptions = calculatePositionOptions(dashboardGridOptions, this);
|
||||
const visualizationOptions = calculatePositionOptions(this);
|
||||
|
||||
this.options = this.options || {};
|
||||
this.options.position = extend(
|
||||
@ -90,13 +91,6 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
|
||||
if (this.options.position.sizeY < 0) {
|
||||
this.options.position.autoHeight = true;
|
||||
}
|
||||
|
||||
this.updateOriginalPosition();
|
||||
}
|
||||
|
||||
updateOriginalPosition() {
|
||||
// Save original position (create a shallow copy)
|
||||
this.$originalPosition = extend({}, this.options.position);
|
||||
}
|
||||
|
||||
getQuery() {
|
||||
@ -151,8 +145,11 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
|
||||
return this.queryResult.toPromise();
|
||||
}
|
||||
|
||||
save() {
|
||||
save(key, value) {
|
||||
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';
|
||||
if (this.id) {
|
||||
@ -164,8 +161,6 @@ function WidgetFactory($http, $location, Query, dashboardGridOptions) {
|
||||
this[k] = v;
|
||||
});
|
||||
|
||||
this.updateOriginalPosition();
|
||||
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { createDashboard, createQuery, addTextbox, addWidget } from '../../suppo
|
||||
|
||||
const { get } = Cypress._;
|
||||
|
||||
const RESIZE_HANDLE_SELECTOR = '.ui-resizable-se';
|
||||
const RESIZE_HANDLE_SELECTOR = '.react-resizable-handle';
|
||||
|
||||
|
||||
function getWidgetTestId(widget) {
|
||||
@ -31,17 +31,19 @@ function editDashboard() {
|
||||
});
|
||||
}
|
||||
|
||||
function dragBy(wrapper, offsetLeft = 0, offsetTop = 0, force = false) {
|
||||
let start;
|
||||
function dragBy(wrapper, offsetLeft, offsetTop, force = false) {
|
||||
if (!offsetLeft) {
|
||||
offsetLeft = 1;
|
||||
}
|
||||
if (!offsetTop) {
|
||||
offsetTop = 1;
|
||||
}
|
||||
return wrapper
|
||||
.then(($el) => {
|
||||
start = $el.offset();
|
||||
return wrapper
|
||||
.trigger('mouseover', { force })
|
||||
.trigger('mousedown', { pageX: start.left, pageY: start.top, which: 1, force })
|
||||
.trigger('mousemove', { pageX: start.left + offsetLeft, pageY: start.top + offsetTop, which: 1, force })
|
||||
.trigger('mouseup', { force });
|
||||
});
|
||||
.trigger('mouseover', { force })
|
||||
.trigger('mousedown', 'topLeft', { force })
|
||||
.trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange
|
||||
.trigger('mousemove', offsetLeft, offsetTop, { force })
|
||||
.trigger('mouseup', { force });
|
||||
}
|
||||
|
||||
function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) {
|
||||
@ -157,8 +159,7 @@ describe('Dashboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('allows opening menu after removal', function () {
|
||||
it('allows opening menu after removal', function () {
|
||||
let elTestId1;
|
||||
addTextbox(this.dashboardId, 'txb 1')
|
||||
.then(getWidgetTestId)
|
||||
@ -235,7 +236,7 @@ describe('Dashboard', () => {
|
||||
const { top, left } = $el.offset();
|
||||
expect(top).to.eq(214);
|
||||
expect(left).to.eq(215);
|
||||
expect($el.width()).to.eq(600);
|
||||
expect($el.width()).to.eq(585);
|
||||
expect($el.height()).to.eq(185);
|
||||
});
|
||||
});
|
||||
@ -300,21 +301,21 @@ describe('Dashboard', () => {
|
||||
resizeBy(cy.get('@textboxEl'), 90)
|
||||
.then(() => cy.get('@textboxEl'))
|
||||
.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', () => {
|
||||
resizeBy(cy.get('@textboxEl'), 110)
|
||||
.then(() => cy.get('@textboxEl'))
|
||||
.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', () => {
|
||||
resizeBy(cy.get('@textboxEl'), 400)
|
||||
.then(() => cy.get('@textboxEl'))
|
||||
.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(() => cy.get('@textboxEl'))
|
||||
.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
|
||||
});
|
||||
});
|
||||
@ -433,7 +434,7 @@ describe('Dashboard', () => {
|
||||
cy.getByTestId('RefreshIndicator').as('refreshButton');
|
||||
});
|
||||
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);
|
||||
|
||||
// resize height by 1 grid row
|
||||
resizeBy(cy.get('@widget'), 0, 5);
|
||||
cy.get('@widget').invoke('height').should('eq', 335);
|
||||
resizeBy(cy.get('@widget'), 0, 50)
|
||||
.then(() => cy.get('@widget'))
|
||||
.invoke('height')
|
||||
.should('eq', 335); // resized by 50, , 135 -> 185
|
||||
|
||||
// add 4 table rows
|
||||
cy.get('@paramInput').clear().type('5{enter}');
|
||||
@ -505,12 +508,12 @@ describe('Dashboard', () => {
|
||||
|
||||
it('shows widgets with full width', () => {
|
||||
cy.get('@textboxEl').should(($el) => {
|
||||
expect($el.width()).to.eq(785);
|
||||
expect($el.width()).to.eq(770);
|
||||
});
|
||||
|
||||
cy.viewport(801, 800);
|
||||
cy.get('@textboxEl').should(($el) => {
|
||||
expect($el.width()).to.eq(393);
|
||||
expect($el.width()).to.eq(378);
|
||||
});
|
||||
});
|
||||
|
||||
|
39
package-lock.json
generated
39
package-lock.json
generated
@ -8912,16 +8912,6 @@
|
||||
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
|
||||
@ -14866,6 +14856,26 @@
|
||||
"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": {
|
||||
"version": "16.8.3",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.23.2.tgz",
|
||||
|
@ -59,7 +59,6 @@
|
||||
"d3-cloud": "^1.2.4",
|
||||
"debug": "^3.1.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"gridstack": "^0.3.0",
|
||||
"hoist-non-react-statics": "^3.3.0",
|
||||
"jquery": "^3.2.1",
|
||||
"jquery-ui": "^1.12.1",
|
||||
@ -81,6 +80,7 @@
|
||||
"react": "^16.8.3",
|
||||
"react-ace": "^6.1.0",
|
||||
"react-dom": "^16.8.3",
|
||||
"react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git",
|
||||
"react2angular": "^3.2.1",
|
||||
"ui-select": "^0.19.8",
|
||||
"underscore.string": "^3.3.4"
|
||||
|
Loading…
Reference in New Issue
Block a user