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="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>
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
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;
|
|
||||||
|
@ -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()
|
||||||
|
@ -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">
|
||||||
|
@ -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 = () => {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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 },
|
||||||
|
@ -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)));
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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
39
package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user