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
Tim Raymond 2017-03-14 10:44:24 -04:00
parent 7bb83ff9a2
commit aaf49d1d81
7 changed files with 230 additions and 64 deletions

View File

@ -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)")
})
})

View File

@ -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

View File

@ -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,

View File

@ -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>
);

View File

@ -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;

View File

@ -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}

View File

@ -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;