Skip to content

Commit 606cf12

Browse files
ranbenaarikfr
authored andcommitted
Dashboard grid React migration #1 (getredash#3722)
* Dashboard grid React migration * Updated tests * Fixes comments * One col layout * Tests unskipped * Test fixes * Test fix * AutoHeight feature * Kebab-cased * Get rid of lazyInjector * Replace react-grid-layout with patched fork to fix performance issues * Fix issue with initial layout when page has a scrollbar * Decrease polling interval (500ms is too slow) * Rename file to match it's contents * Added some notes and very minor fixes * Fix Remove widget button (should be visible only in editing mode); fix widget actions menu * Fixed missing grid markings * Enhanced resize handle * Updated placeholder color * Render DashboardGrid only when dashboard is loaded
1 parent 4508975 commit 606cf12

21 files changed

+574
-713
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { includes, reduce, some } from 'lodash';
2+
3+
// TODO: Revisit this implementation when migrating widget component to React
4+
5+
const WIDGET_SELECTOR = '[data-widgetid="{0}"]';
6+
const WIDGET_CONTENT_SELECTOR = [
7+
'.widget-header', // header
8+
'visualization-renderer', // visualization
9+
'.scrollbox .alert', // error state
10+
'.spinner-container', // loading state
11+
'.tile__bottom-control', // footer
12+
].join(',');
13+
const INTERVAL = 200;
14+
15+
export default class AutoHeightController {
16+
widgets = {};
17+
18+
interval = null;
19+
20+
onHeightChange = null;
21+
22+
constructor(handler) {
23+
this.onHeightChange = handler;
24+
}
25+
26+
update(widgets) {
27+
const newWidgetIds = widgets
28+
.filter(widget => widget.options.position.autoHeight)
29+
.map(widget => widget.id.toString());
30+
31+
// added
32+
newWidgetIds
33+
.filter(id => !includes(Object.keys(this.widgets), id))
34+
.forEach(this.add);
35+
36+
// removed
37+
Object.keys(this.widgets)
38+
.filter(id => !includes(newWidgetIds, id))
39+
.forEach(this.remove);
40+
}
41+
42+
add = (id) => {
43+
if (this.isEmpty()) {
44+
this.start();
45+
}
46+
47+
const selector = WIDGET_SELECTOR.replace('{0}', id);
48+
this.widgets[id] = [
49+
function getHeight() {
50+
const widgetEl = document.querySelector(selector);
51+
if (!widgetEl) {
52+
return undefined; // safety
53+
}
54+
55+
// get all content elements
56+
const els = widgetEl.querySelectorAll(WIDGET_CONTENT_SELECTOR);
57+
58+
// calculate accumulated height
59+
return reduce(els, (acc, el) => {
60+
const height = el ? el.getBoundingClientRect().height : 0;
61+
return acc + height;
62+
}, 0);
63+
},
64+
];
65+
};
66+
67+
remove = (id) => {
68+
// not actually deleting from this.widgets to prevent case of unwanted re-adding
69+
this.widgets[id.toString()] = false;
70+
71+
if (this.isEmpty()) {
72+
this.stop();
73+
}
74+
};
75+
76+
exists = id => !!this.widgets[id.toString()];
77+
78+
isEmpty = () => !some(this.widgets);
79+
80+
checkHeightChanges = () => {
81+
Object.keys(this.widgets).forEach((id) => {
82+
const [getHeight, prevHeight] = this.widgets[id];
83+
const height = getHeight();
84+
if (height && height !== prevHeight) {
85+
this.widgets[id][1] = height; // save
86+
this.onHeightChange(id, height); // dispatch
87+
}
88+
});
89+
};
90+
91+
start = () => {
92+
this.stop();
93+
this.interval = setInterval(this.checkHeightChanges, INTERVAL);
94+
};
95+
96+
stop = () => {
97+
clearInterval(this.interval);
98+
};
99+
100+
resume = () => {
101+
if (!this.isEmpty()) {
102+
this.start();
103+
}
104+
};
105+
106+
destroy = () => {
107+
this.stop();
108+
this.widgets = null;
109+
}
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { chain, cloneDeep, find } from 'lodash';
4+
import { react2angular } from 'react2angular';
5+
import cx from 'classnames';
6+
import { Responsive, WidthProvider } from 'react-grid-layout';
7+
import { DashboardWidget } from '@/components/dashboards/widget';
8+
import { FiltersType } from '@/components/Filters';
9+
import cfg from '@/config/dashboard-grid-options';
10+
import AutoHeightController from './AutoHeightController';
11+
12+
import 'react-grid-layout/css/styles.css';
13+
import './dashboard-grid.less';
14+
15+
const ResponsiveGridLayout = WidthProvider(Responsive);
16+
17+
const WidgetType = PropTypes.shape({
18+
id: PropTypes.number.isRequired,
19+
options: PropTypes.shape({
20+
position: PropTypes.shape({
21+
col: PropTypes.number.isRequired,
22+
row: PropTypes.number.isRequired,
23+
sizeY: PropTypes.number.isRequired,
24+
minSizeY: PropTypes.number.isRequired,
25+
maxSizeY: PropTypes.number.isRequired,
26+
sizeX: PropTypes.number.isRequired,
27+
minSizeX: PropTypes.number.isRequired,
28+
maxSizeX: PropTypes.number.isRequired,
29+
}).isRequired,
30+
}).isRequired,
31+
});
32+
33+
const SINGLE = 'single-column';
34+
const MULTI = 'multi-column';
35+
36+
class DashboardGrid extends React.Component {
37+
static propTypes = {
38+
isEditing: PropTypes.bool.isRequired,
39+
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
40+
widgets: PropTypes.arrayOf(WidgetType).isRequired,
41+
filters: FiltersType,
42+
onBreakpointChange: PropTypes.func,
43+
onRemoveWidget: PropTypes.func,
44+
onLayoutChange: PropTypes.func,
45+
};
46+
47+
static defaultProps = {
48+
filters: [],
49+
onRemoveWidget: () => {},
50+
onLayoutChange: () => {},
51+
onBreakpointChange: () => {},
52+
};
53+
54+
static normalizeFrom(widget) {
55+
const { id, options: { position: pos } } = widget;
56+
57+
return {
58+
i: id.toString(),
59+
x: pos.col,
60+
y: pos.row,
61+
w: pos.sizeX,
62+
h: pos.sizeY,
63+
minW: pos.minSizeX,
64+
maxW: pos.maxSizeX,
65+
minH: pos.minSizeY,
66+
maxH: pos.maxSizeY,
67+
};
68+
}
69+
70+
mode = null;
71+
72+
autoHeightCtrl = null;
73+
74+
constructor(props) {
75+
super(props);
76+
77+
this.state = {
78+
layouts: {},
79+
disableAnimations: true,
80+
};
81+
82+
// init AutoHeightController
83+
this.autoHeightCtrl = new AutoHeightController(this.onWidgetHeightUpdated);
84+
this.autoHeightCtrl.update(this.props.widgets);
85+
}
86+
87+
componentDidMount() {
88+
this.onBreakpointChange(document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI);
89+
// Work-around to disable initial animation on widgets; `measureBeforeMount` doesn't work properly:
90+
// it disables animation, but it cannot detect scrollbars.
91+
setTimeout(() => {
92+
this.setState({ disableAnimations: false });
93+
}, 50);
94+
}
95+
96+
componentDidUpdate() {
97+
// update, in case widgets added or removed
98+
this.autoHeightCtrl.update(this.props.widgets);
99+
}
100+
101+
componentWillUnmount() {
102+
this.autoHeightCtrl.destroy();
103+
}
104+
105+
onLayoutChange = (_, layouts) => {
106+
// workaround for when dashboard starts at single mode and then multi is empty or carries single col data
107+
// fixes test dashboard_spec['shows widgets with full width']
108+
// TODO: open react-grid-layout issue
109+
if (layouts[MULTI]) {
110+
this.setState({ layouts });
111+
}
112+
113+
// workaround for https://github.com/STRML/react-grid-layout/issues/889
114+
// remove next line when fix lands
115+
this.mode = document.body.offsetWidth <= cfg.mobileBreakPoint ? SINGLE : MULTI;
116+
// end workaround
117+
118+
// don't save single column mode layout
119+
if (this.mode === SINGLE) {
120+
return;
121+
}
122+
123+
const normalized = chain(layouts[MULTI])
124+
.keyBy('i')
125+
.mapValues(this.normalizeTo)
126+
.value();
127+
128+
this.props.onLayoutChange(normalized);
129+
};
130+
131+
onBreakpointChange = (mode) => {
132+
this.mode = mode;
133+
this.props.onBreakpointChange(mode === SINGLE);
134+
};
135+
136+
// height updated by auto-height
137+
onWidgetHeightUpdated = (widgetId, newHeight) => {
138+
this.setState(({ layouts }) => {
139+
const layout = cloneDeep(layouts[MULTI]); // must clone to allow react-grid-layout to compare prev/next state
140+
const item = find(layout, { i: widgetId.toString() });
141+
if (item) {
142+
// update widget height
143+
item.h = Math.ceil((newHeight + cfg.margins) / cfg.rowHeight);
144+
}
145+
146+
return { layouts: { [MULTI]: layout } };
147+
});
148+
};
149+
150+
// height updated by manual resize
151+
onWidgetResize = (layout, oldItem, newItem) => {
152+
if (oldItem.h !== newItem.h) {
153+
this.autoHeightCtrl.remove(Number(newItem.i));
154+
}
155+
156+
this.autoHeightCtrl.resume();
157+
};
158+
159+
normalizeTo = layout => ({
160+
col: layout.x,
161+
row: layout.y,
162+
sizeX: layout.w,
163+
sizeY: layout.h,
164+
autoHeight: this.autoHeightCtrl.exists(layout.i),
165+
});
166+
167+
render() {
168+
const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode');
169+
const { onRemoveWidget, dashboard, widgets } = this.props;
170+
171+
return (
172+
<div className={className}>
173+
<ResponsiveGridLayout
174+
className={cx('layout', { 'disable-animations': this.state.disableAnimations })}
175+
cols={{ [MULTI]: cfg.columns, [SINGLE]: 1 }}
176+
rowHeight={cfg.rowHeight - cfg.margins}
177+
margin={[cfg.margins, cfg.margins]}
178+
isDraggable={this.props.isEditing}
179+
isResizable={this.props.isEditing}
180+
onResizeStart={this.autoHeightCtrl.stop}
181+
onResizeStop={this.onWidgetResize}
182+
layouts={this.state.layouts}
183+
onLayoutChange={this.onLayoutChange}
184+
onBreakpointChange={this.onBreakpointChange}
185+
breakpoints={{ [MULTI]: cfg.mobileBreakPoint, [SINGLE]: 0 }}
186+
>
187+
{widgets.map(widget => (
188+
<div
189+
key={widget.id}
190+
data-grid={DashboardGrid.normalizeFrom(widget)}
191+
data-widgetid={widget.id}
192+
data-test={`WidgetId${widget.id}`}
193+
className={cx('dashboard-widget-wrapper', { 'widget-auto-height-enabled': this.autoHeightCtrl.exists(widget.id) })}
194+
>
195+
<DashboardWidget
196+
widget={widget}
197+
dashboard={dashboard}
198+
filters={this.props.filters}
199+
deleted={() => onRemoveWidget(widget.id)}
200+
/>
201+
</div>
202+
))}
203+
</ResponsiveGridLayout>
204+
</div>
205+
);
206+
}
207+
}
208+
209+
export default function init(ngModule) {
210+
ngModule.component('dashboardGrid', react2angular(DashboardGrid));
211+
}
212+
213+
init.init = true;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.react-grid-layout {
2+
&.disable-animations {
3+
& > .react-grid-item {
4+
transition: none !important;
5+
}
6+
}
7+
}

0 commit comments

Comments
 (0)