Skip to content

Commit

Permalink
Dashboard grid React migration #1 (getredash#3722)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ranbena authored and arikfr committed May 16, 2019
1 parent 4508975 commit 606cf12
Show file tree
Hide file tree
Showing 21 changed files with 574 additions and 713 deletions.
110 changes: 110 additions & 0 deletions client/app/components/dashboards/AutoHeightController.js
Original file line number Diff line number Diff line change
@@ -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 changes: 213 additions & 0 deletions client/app/components/dashboards/DashboardGrid.jsx
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions client/app/components/dashboards/dashboard-grid.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.react-grid-layout {
&.disable-animations {
& > .react-grid-item {
transition: none !important;
}
}
}
Loading

0 comments on commit 606cf12

Please sign in to comment.