forked from getredash/redash
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
21 changed files
with
574 additions
and
713 deletions.
There are no files selected for viewing
110 changes: 110 additions & 0 deletions
110
client/app/components/dashboards/AutoHeightController.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
Oops, something went wrong.