Implement NameableGraph as a stateless component
NameableGraph is now a stateless component, with all its ephemeral state held within Redux. This improves its testability, and two tests have been added for the two needed Reducer cases. Also, since NameableGraph's behavior is entirely controlled by its props, the component itself can be tested, though this has not yet been done.pull/1020/head
parent
7bb83ff9a2
commit
aaf49d1d81
|
@ -6,6 +6,8 @@ import {
|
|||
setDashboard,
|
||||
setTimeRange,
|
||||
setEditMode,
|
||||
editCell,
|
||||
renameCell,
|
||||
} from 'src/dashboards/actions'
|
||||
|
||||
const noopAction = () => {
|
||||
|
@ -49,4 +51,50 @@ describe('DataExplorer.Reducers.UI', () => {
|
|||
const actual = reducer(state, setEditMode(isEditMode))
|
||||
expect(actual.isEditMode).to.equal(isEditMode)
|
||||
})
|
||||
|
||||
it('can edit cell', () => {
|
||||
const dash = {
|
||||
id: 1,
|
||||
cells: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 4,
|
||||
id: 1,
|
||||
isEditing: false,
|
||||
name: "Gigawatts",
|
||||
}],
|
||||
}
|
||||
|
||||
state = {
|
||||
dashboard: dash,
|
||||
dashboards: [dash],
|
||||
}
|
||||
|
||||
const actual = reducer(state, editCell(0, 0, true))
|
||||
expect(actual.dashboards[0].cells[0].isEditing).to.equal(true)
|
||||
})
|
||||
|
||||
it('can rename cells', () => {
|
||||
const dash = {
|
||||
id: 1,
|
||||
cells: [{
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 4,
|
||||
id: 1,
|
||||
isEditing: true,
|
||||
name: "Gigawatts",
|
||||
}],
|
||||
}
|
||||
|
||||
state = {
|
||||
dashboard: dash,
|
||||
dashboards: [dash],
|
||||
}
|
||||
|
||||
const actual = reducer(state, renameCell(0, 0, "Plutonium Consumption Rate (ug/sec)"))
|
||||
expect(actual.dashboards[0].cells[0].name).to.equal("Plutonium Consumption Rate (ug/sec)")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -39,6 +39,27 @@ export const updateDashboard = (dashboard) => ({
|
|||
},
|
||||
})
|
||||
|
||||
export const editCell = (x, y, isEditing) => ({
|
||||
type: 'EDIT_CELL',
|
||||
// x and y coords are used as a alternative to cell ids, which are not
|
||||
// universally unique, and cannot be because React depends on a
|
||||
// quasi-predictable ID for keys. Since cells cannot overlap, coordinates act
|
||||
// as a suitable id
|
||||
payload: {
|
||||
x, // x-coord of the cell to be edited
|
||||
y, // y-coord of the cell to be edited
|
||||
isEditing,
|
||||
},
|
||||
})
|
||||
|
||||
export const renameCell = (x, y, name) => ({
|
||||
type: 'RENAME_CELL',
|
||||
payload: {
|
||||
x, // x-coord of the cell to be renamed
|
||||
y, // y-coord of the cell to be renamed
|
||||
name,
|
||||
},
|
||||
})
|
||||
|
||||
// Async Action Creators
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ const Dashboard = ({
|
|||
inPresentationMode,
|
||||
onPositionChange,
|
||||
onEditCell,
|
||||
onRenameCell,
|
||||
onUpdateCell,
|
||||
source,
|
||||
autoRefresh,
|
||||
timeRange,
|
||||
|
@ -22,13 +24,13 @@ const Dashboard = ({
|
|||
<div className={classnames({'page-contents': true, 'presentation-mode': inPresentationMode})}>
|
||||
<div className={classnames('container-fluid full-width dashboard', {'dashboard-edit': isEditMode})}>
|
||||
{isEditMode ? <Visualizations/> : null}
|
||||
{Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell)}
|
||||
{Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell) => {
|
||||
Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell) => {
|
||||
const cells = dashboard.cells.map((cell, i) => {
|
||||
i = `${i}`
|
||||
const dashboardCell = {...cell, i}
|
||||
|
@ -47,6 +49,8 @@ Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositi
|
|||
source={source.links.proxy}
|
||||
onPositionChange={onPositionChange}
|
||||
onEditCell={onEditCell}
|
||||
onRenameCell={onRenameCell}
|
||||
onUpdateCell={onUpdateCell}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -65,6 +69,8 @@ Dashboard.propTypes = {
|
|||
inPresentationMode: bool,
|
||||
onPositionChange: func,
|
||||
onEditCell: func,
|
||||
onRenameCell: func,
|
||||
onUpdateCell: func,
|
||||
source: shape({
|
||||
links: shape({
|
||||
proxy: string,
|
||||
|
|
|
@ -42,6 +42,8 @@ const DashboardPage = React.createClass({
|
|||
setDashboard: func.isRequired,
|
||||
setTimeRange: func.isRequired,
|
||||
setEditMode: func.isRequired,
|
||||
editCell: func.isRequired,
|
||||
renameCell: func.isRequired,
|
||||
}).isRequired,
|
||||
dashboards: arrayOf(shape({
|
||||
id: number.isRequired,
|
||||
|
@ -92,16 +94,32 @@ const DashboardPage = React.createClass({
|
|||
this.props.dashboardActions.putDashboard({...this.props.dashboard, cells})
|
||||
},
|
||||
|
||||
handleEditCell(cell) {
|
||||
const {cells} = this.props.dashboard
|
||||
const targetIdx = cells.findIndex((c) => cell.x === c.x && cell.y === c.y && cell.h === c.h && cell.w === c.w)
|
||||
// Places cell into editing mode.
|
||||
handleEditCell(x, y, isEditing) {
|
||||
return () => {
|
||||
this.props.dashboardActions.editCell(x, y, !isEditing) /* eslint-disable no-negated-condition */
|
||||
}
|
||||
},
|
||||
|
||||
const newCells = [
|
||||
...cells.slice(0, targetIdx),
|
||||
cell,
|
||||
...cells.slice(targetIdx + 1),
|
||||
]
|
||||
this.props.dashboardActions.putDashboard({...this.props.dashboard, cells: newCells})
|
||||
handleChangeCellName(x, y) {
|
||||
return (evt) => {
|
||||
this.props.dashboardActions.renameCell(x, y, evt.target.value)
|
||||
}
|
||||
},
|
||||
|
||||
handleUpdateCell(newCell) {
|
||||
return () => {
|
||||
const {cells} = this.props.dashboard
|
||||
const cellIdx = cells.findIndex((c) => c.x === newCell.x && c.y === newCell.y)
|
||||
|
||||
this.handleUpdatePosition([
|
||||
...cells.slice(0, cellIdx),
|
||||
newCell,
|
||||
...cells.slice(cellIdx + 1),
|
||||
])
|
||||
|
||||
this.props.dashboardActions.editCell(newCell.x, newCell.y, false)
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
|
@ -153,6 +171,8 @@ const DashboardPage = React.createClass({
|
|||
timeRange={timeRange}
|
||||
onPositionChange={this.handleUpdatePosition}
|
||||
onEditCell={this.handleEditCell}
|
||||
onRenameCell={this.handleChangeCellName}
|
||||
onUpdateCell={this.handleUpdateCell}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -50,6 +50,62 @@ export default function ui(state = initialState, action) {
|
|||
|
||||
return {...state, ...newState}
|
||||
}
|
||||
|
||||
case 'EDIT_CELL': {
|
||||
const {x, y, isEditing} = action.payload
|
||||
const {dashboard} = state
|
||||
|
||||
const cellIdx = dashboard.cells.findIndex((cell) => cell.x === x && cell.y === y)
|
||||
|
||||
const newCell = {
|
||||
...dashboard.cells[cellIdx],
|
||||
isEditing,
|
||||
}
|
||||
|
||||
const newDashboard = {
|
||||
...dashboard,
|
||||
cells: [
|
||||
...dashboard.cells.slice(0, cellIdx),
|
||||
newCell,
|
||||
...dashboard.cells.slice(cellIdx + 1),
|
||||
],
|
||||
}
|
||||
|
||||
const newState = {
|
||||
newDashboard,
|
||||
dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d),
|
||||
}
|
||||
|
||||
return {...state, ...newState}
|
||||
}
|
||||
|
||||
case 'RENAME_CELL': {
|
||||
const {x, y, name} = action.payload
|
||||
const {dashboard} = state
|
||||
|
||||
const cellIdx = dashboard.cells.findIndex((cell) => cell.x === x && cell.y === y)
|
||||
|
||||
const newCell = {
|
||||
...dashboard.cells[cellIdx],
|
||||
name,
|
||||
}
|
||||
|
||||
const newDashboard = {
|
||||
...dashboard,
|
||||
cells: [
|
||||
...dashboard.cells.slice(0, cellIdx),
|
||||
newCell,
|
||||
...dashboard.cells.slice(cellIdx + 1),
|
||||
],
|
||||
}
|
||||
|
||||
const newState = {
|
||||
newDashboard,
|
||||
dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d),
|
||||
}
|
||||
|
||||
return {...state, ...newState}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
|
|
|
@ -52,6 +52,8 @@ export const LayoutRenderer = React.createClass({
|
|||
source: string,
|
||||
onPositionChange: func,
|
||||
onEditCell: func,
|
||||
onRenameCell: func,
|
||||
onUpdateCell: func,
|
||||
},
|
||||
|
||||
buildQuery(q) {
|
||||
|
@ -86,7 +88,7 @@ export const LayoutRenderer = React.createClass({
|
|||
},
|
||||
|
||||
generateVisualizations() {
|
||||
const {autoRefresh, source, cells} = this.props;
|
||||
const {autoRefresh, source, cells, onEditCell, onRenameCell, onUpdateCell} = this.props;
|
||||
|
||||
return cells.map((cell) => {
|
||||
const qs = cell.queries.map((q) => {
|
||||
|
@ -99,7 +101,12 @@ export const LayoutRenderer = React.createClass({
|
|||
if (cell.type === 'single-stat') {
|
||||
return (
|
||||
<div key={cell.i}>
|
||||
<NameableGraph cell={cell} onRename={this.props.onEditCell}>
|
||||
<NameableGraph
|
||||
onEditCell={onEditCell}
|
||||
onRenameCell={onRenameCell}
|
||||
onUpdateCell={onUpdateCell}
|
||||
cell={cell}
|
||||
>
|
||||
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefresh} />
|
||||
</NameableGraph>
|
||||
</div>
|
||||
|
@ -113,7 +120,12 @@ export const LayoutRenderer = React.createClass({
|
|||
|
||||
return (
|
||||
<div key={cell.i}>
|
||||
<NameableGraph cell={cell} onRename={this.props.onEditCell}>
|
||||
<NameableGraph
|
||||
onEditCell={onEditCell}
|
||||
onRenameCell={onRenameCell}
|
||||
onUpdateCell={onUpdateCell}
|
||||
cell={cell}
|
||||
>
|
||||
<RefreshingLineGraph
|
||||
queries={qs}
|
||||
autoRefresh={autoRefresh}
|
||||
|
|
|
@ -1,58 +1,61 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
|
||||
const NameableGraph = React.createClass({
|
||||
propTypes: {
|
||||
cell: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onRename: PropTypes.func.isRequired,
|
||||
const NameableGraph = ({
|
||||
cell: {
|
||||
name,
|
||||
isEditing,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
cell,
|
||||
children,
|
||||
onEditCell,
|
||||
onRenameCell,
|
||||
onUpdateCell,
|
||||
}) => {
|
||||
let nameOrField
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
editing: false,
|
||||
name: this.props.cell.name,
|
||||
}
|
||||
},
|
||||
if (isEditing) {
|
||||
nameOrField = (
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
autoFocus={true}
|
||||
onChange={onRenameCell(x, y)}
|
||||
onBlur={onUpdateCell(cell)}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
nameOrField = name
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.setState({
|
||||
editing: !this.state.editing, /* eslint-disable no-negated-condition */
|
||||
});
|
||||
},
|
||||
|
||||
handleChangeName() {
|
||||
this.props.onRename({
|
||||
...this.props.cell,
|
||||
name: this.state.name,
|
||||
})
|
||||
},
|
||||
|
||||
handleChange(evt) {
|
||||
this.setState({
|
||||
name: evt.target.value,
|
||||
})
|
||||
},
|
||||
|
||||
render() {
|
||||
let nameOrField
|
||||
if (!this.state.editing) {
|
||||
nameOrField = this.props.cell.name
|
||||
} else {
|
||||
nameOrField = <input type="text" value={this.state.name} autoFocus={true} onChange={this.handleChange} onBlur={this.handleChangeName}></input>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="dash-graph--heading" onClick={this.handleClick}>{nameOrField}</h2>
|
||||
<div className="dash-graph--container">
|
||||
{this.props.children}
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<h2 className="dash-graph--heading" onClick={onEditCell(x, y, isEditing)}>{nameOrField}</h2>
|
||||
<div className="dash-graph--container">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
func,
|
||||
node,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
NameableGraph.propTypes = {
|
||||
cell: shape({
|
||||
name: string.isRequired,
|
||||
x: string.isRequired,
|
||||
y: string.isRequired,
|
||||
}).isRequired,
|
||||
children: node.isRequired,
|
||||
onEditCell: func.isRequired,
|
||||
onRenameCell: func.isRequired,
|
||||
onUpdateCell: func.isRequired,
|
||||
}
|
||||
|
||||
export default NameableGraph;
|
||||
|
|
Loading…
Reference in New Issue