client/app/components/dashboards/DashboardGrid.jsx (256 lines of code) (raw):
import React from "react";
import PropTypes from "prop-types";
import { chain, cloneDeep, find } from "lodash";
import cx from "classnames";
import { Responsive, WidthProvider } from "react-grid-layout";
import { VisualizationWidget, TextboxWidget, RestrictedWidget } from "@/components/dashboards/dashboard-widget";
import { FiltersType } from "@/components/Filters";
import cfg from "@/config/dashboard-grid-options";
import AutoHeightController from "./AutoHeightController";
import { WidgetTypeEnum } from "@/services/widget";
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";
const DashboardWidget = React.memo(
function DashboardWidget({
widget,
dashboard,
onLoadWidget,
onRefreshWidget,
onRemoveWidget,
onParameterMappingsChange,
isEditing,
canEdit,
isPublic,
isLoading,
filters,
}) {
const { type } = widget;
const onLoad = () => onLoadWidget(widget);
const onRefresh = () => onRefreshWidget(widget);
const onDelete = () => onRemoveWidget(widget.id);
if (type === WidgetTypeEnum.VISUALIZATION) {
return (
<VisualizationWidget
widget={widget}
dashboard={dashboard}
filters={filters}
isEditing={isEditing}
canEdit={canEdit}
isPublic={isPublic}
isLoading={isLoading}
onLoad={onLoad}
onRefresh={onRefresh}
onDelete={onDelete}
onParameterMappingsChange={onParameterMappingsChange}
/>
);
}
if (type === WidgetTypeEnum.TEXTBOX) {
return <TextboxWidget widget={widget} canEdit={canEdit} isPublic={isPublic} onDelete={onDelete} />;
}
return <RestrictedWidget widget={widget} />;
},
(prevProps, nextProps) =>
prevProps.widget === nextProps.widget &&
prevProps.canEdit === nextProps.canEdit &&
prevProps.isPublic === nextProps.isPublic &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.filters === nextProps.filters &&
prevProps.isEditing === nextProps.isEditing
);
class DashboardGrid extends React.Component {
static propTypes = {
isEditing: PropTypes.bool.isRequired,
isPublic: PropTypes.bool,
dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
widgets: PropTypes.arrayOf(WidgetType).isRequired,
filters: FiltersType,
onBreakpointChange: PropTypes.func,
onLoadWidget: PropTypes.func,
onRefreshWidget: PropTypes.func,
onRemoveWidget: PropTypes.func,
onLayoutChange: PropTypes.func,
onParameterMappingsChange: PropTypes.func,
};
static defaultProps = {
isPublic: false,
filters: [],
onLoadWidget: () => {},
onRefreshWidget: () => {},
onRemoveWidget: () => {},
onLayoutChange: () => {},
onBreakpointChange: () => {},
onParameterMappingsChange: () => {},
};
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 {
onLoadWidget,
onRefreshWidget,
onRemoveWidget,
onParameterMappingsChange,
filters,
dashboard,
isPublic,
isEditing,
widgets,
} = this.props;
const className = cx("dashboard-wrapper", isEditing ? "editing-mode" : "preview-mode");
return (
<div className={className}>
<ResponsiveGridLayout
draggableCancel="input,.sortable-container"
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={isEditing}
isResizable={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
dashboard={dashboard}
widget={widget}
filters={filters}
isPublic={isPublic}
isLoading={widget.loading}
isEditing={isEditing}
canEdit={dashboard.canEdit()}
onLoadWidget={onLoadWidget}
onRefreshWidget={onRefreshWidget}
onRemoveWidget={onRemoveWidget}
onParameterMappingsChange={onParameterMappingsChange}
/>
</div>
))}
</ResponsiveGridLayout>
</div>
);
}
}
export default DashboardGrid;