Adds tests for action creators and reducers

This commit is contained in:
Patrick McDonagh
2018-04-12 18:33:35 -05:00
parent 6f7381c75d
commit 8d208906c8
31 changed files with 2458 additions and 121 deletions

File diff suppressed because one or more lines are too long

View File

@@ -54,3 +54,17 @@ body {
width: 95%;
}
.alarms-active {
animation: pulse 0.5s infinite;
}
@keyframes pulse {
0% {
background-color: #FFFFFF !important;
color: #dc3545 !important;
}
100% {
background-color: #dc3545 !important;
color: #FFFFFF !important;
}
}

View File

@@ -3,6 +3,7 @@ import { ipcRenderer } from "electron";
export const SET_PLC_IPADDRESS = "SET_PLC_IPADDRESS";
export const INITIALIZE_PLC = "INITIALIZE_PLC";
export const PLC_DATA_RECEIVED = "PLC_DATA_RECEIVED";
export const PLC_ERROR_RECEIVED = "PLC_ERROR_RECEIVED";
export function setPlcIpAddress(ipAddress){
@@ -21,10 +22,16 @@ export function ipcPlcInitializeSend(ipAddress, tagList){
}
export function ipcPlcDetailsReceived(event, plcData){
console.log("action creator got PLC data", plcData);
return {
type: PLC_DATA_RECEIVED,
payload: plcData
};
}
export function ipcPlcErrorReceived(event, plcError){
return {
type: PLC_ERROR_RECEIVED,
payload: plcError
};
}

View File

@@ -251,6 +251,13 @@ class Controls extends Component {
}
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
tags: state.tags

View File

@@ -0,0 +1,75 @@
import _ from "lodash";
import React, { Component } from "react";
import { connect } from "react-redux";
import {Timeline, TimelineEvent } from "react-event-timeline";
import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faExclamationTriangle, faCalendar } from "@fortawesome/fontawesome-pro-regular";
import { writeTag } from "../actions/actions_tags";
class EventLog extends Component {
renderTimelineEvents(){
return _.map(this.props.events, (event, key) => {
let icon = <FontAwesomeIcon icon={faCalendar} />;
let cardHeaderStyle = {backgroundColor: "#007bff"};
if (event.eventType === "alarm"){
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
cardHeaderStyle = {backgroundColor: "#dc3545"};
}
return (
<TimelineEvent
title={event.tag}
createdAt={event.timestamp.toString()}
icon={icon}
iconColor={event.eventType === "alarm" ? "#dc3545" : "#007bff"}
container="card"
key={key}
cardHeaderStyle={cardHeaderStyle}
>
{event.text}
</TimelineEvent>
);
});
}
render(){
if (!this.props.events){
return(
<div>
<h1>Loading...</h1>
</div>
);
}
return (
<div className="container">
<button
className="btn btn-primary"
onClick={() => this.props.writeTag("cmd_ResetAlarms", true)}
style={{marginTop: "10px", marginBottom: "10px"}}
>
Reset Alarms
</button>
<hr />
<Timeline>{this.renderTimelineEvents()}</Timeline>
</div>
);
}
}
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
events: state.events
};
}
export default connect(mapStateToProps, { writeTag })(EventLog);

View File

@@ -3,8 +3,19 @@ import { connect } from "react-redux";
import { Link } from "react-router-dom";
import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faTint, faCog } from "@fortawesome/fontawesome-pro-regular";
/**
* Class to render Header navigation bar
*
* @extends React.Component
*/
class Header extends Component {
/**
* renders the running state indicator button
*
* @returns {button} inactive running state button
*/
renderRunningState(){
if(!this.props.tags ){
return <span></span>;
@@ -46,6 +57,36 @@ class Header extends Component {
}
/**
* renders the alarm button
*
* @returns {button} button showing alarm status with link to alarm log
*/
renderAlarmButton(){
if (!this.props.alarms){
return <span></span>;
}
const btnClassName = this.props.alarms.length > 0 ? "btn btn-danger alarms-active" : "btn btn-light";
const btnText = this.props.alarms.length > 0 ? `Alarms Active: ${this.props.alarms.length}` : "No Alarms";
return (
<Link
style={{marginRight: "10px"}}
to="/events"
className={btnClassName}
>{btnText}
</Link>
);
}
/**
* render method
*
* @returns markup
*/
render(){
return (
<nav className="navbar navbar-light navbar-expand-sm bg-light">
@@ -58,6 +99,7 @@ class Header extends Component {
<Link className="nav-item nav-link" to="/alltags">All Tags</Link>
</div>
<div className="navbar-nav ml-auto">
<span className="navbar-text">{this.renderAlarmButton()}</span>
<span className="navbar-text">{this.renderRunningState()}</span>
<Link className="nav-item nav-link" to="/settings" id="settings-button"><FontAwesomeIcon icon={faCog} /></Link>
</div>
@@ -66,9 +108,17 @@ class Header extends Component {
}
}
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
tags: state.tags
tags: state.tags,
alarms: state.alarms
};
}

View File

@@ -2,17 +2,167 @@ import React, { Component } from "react";
import _ from "lodash";
import { connect } from "react-redux";
import { FlexibleWidthXYPlot, LineSeries, VerticalGridLines, HorizontalGridLines, XAxis, YAxis, DiscreteColorLegend } from "react-vis";
import "../../../node_modules/react-vis/dist/style.css";
// import "../../../node_modules/react-vis/dist/style.css";
import { color } from "d3-color";
import { interpolateRgb } from "d3-interpolate";
import LiquidFillGauge from "react-liquid-gauge";
import { RadialGauge } from "react-canvas-gauges";
/** Class for Main Page
*
* @extends React.Component
*/
class Main extends Component {
getChartValues(values){
/**
* Map an array of objects to a graph-usable array of objects
*
* @param {Array} values - list of objects with timestame and value properties
*
* @returns {Array} list of objects with x and y properties
*/
mapTimestampAndValuePropToXY(values){
return _.map(values, (val) => {
return {x: val.timestamp, y: val.value};
});
}
/**
* Renders a Liquid Gauge.
*
* @param {object} tag - the tag structure
* @param {string} units - units for the tag
* @param {number} maxValue - maximum value to be displayed
* @param {number} label - label for the value (typically the tag name)
* @returns {ReactElement} liquidGauge
*/
renderLiquidGauge(tag, units, maxValue, label){
if (!tag){
return <span></span>;
}
const tagValue = tag.value;
const endColor = "#1F4788";
const startColor = "#dc143c";
const radius = 100;
const interpolate = interpolateRgb(startColor, endColor);
const fillColor = interpolate(tagValue / maxValue);
const gradientStops = [
{
key: "0%",
stopColor: color(fillColor).darker(0.5).toString(),
stopOpacity: 1,
offset: "0%"
},
{
key: "50%",
stopColor: fillColor,
stopOpacity: 0.75,
offset: "50%"
},
{
key: "100%",
stopColor: color(fillColor).brighter(0.5).toString(),
stopOpacity: 0.5,
offset: "100%"
}
];
return (
<div className="col" style={{textAlign: "center"}}>
<h3>{label}</h3>
<LiquidFillGauge
style={{ margin: "0 auto" }}
width={radius * 2}
height={radius * 2}
value={tagValue / maxValue * 100}
percent={units}
textSize={1}
textOffsetX={0}
textOffsetY={0}
textRenderer={(props) => {
const value = Math.round(tagValue);
const radius = Math.min(props.height / 2, props.width / 2);
const textPixels = (props.textSize * radius / 2);
const valueStyle = {
fontSize: textPixels
};
const percentStyle = {
fontSize: textPixels * 0.6
};
return (
<tspan>
<tspan className="value" style={valueStyle}>{value}</tspan>
<tspan style={percentStyle}>{props.percent}</tspan>
</tspan>
);
}}
riseAnimation
waveAnimation
waveFrequency={3}
waveAmplitude={2}
gradient
gradientStops={gradientStops}
circleStyle={{
fill: fillColor
}}
waveStyle={{
fill: fillColor
}}
textStyle={{
fill: color("#444").toString(),
fontFamily: "Arial"
}}
waveTextStyle={{
fill: color("#fff").toString(),
fontFamily: "Arial"
}}
/>
</div>
);
}
/**
* Renders a radial gauge.
*
* @param {Object} tag - the tag structure
* @param {string} units - units for the tag
* @param {number} maxValue - maximum value to be displayed
* @param {number} label - label for the value (typically the tag name)
*/
renderRadialGauge(tag, units, maxValue, label){
if (!tag){
return <span></span>;
}
const ticks = [ 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0 ];
return (
<div className="col" style={{textAlign: "center"}}>
<h3>{label}</h3>
<RadialGauge
height={200}
width={200}
units={units}
title={label}
value={tag.value}
minValue={0}
maxValue={maxValue}
majorTicks={ticks.map((t) => t * maxValue)}
minorTicks={2}
></RadialGauge>
</div>
);
}
/**
* render
*
* @returns {ReactElement} markup
*/
render(){
if (!this.props.tagHistory){
return(
@@ -22,10 +172,33 @@ class Main extends Component {
);
}
if (this.props.plc.error){
return(
<div className="container plc-error">
<h1>PLC Error</h1>
<h3>{this.props.plc.error}</h3>
</div>
);
}
if (Object.keys(this.props.tags).length === 0){
return(
<div className="container">
<h1>Waiting for data...</h1>
</div>
);
}
return (
<div className="container">
<h3>Process Values</h3>
<div className="row" style={{marginBottom: "20px"}}>
{this.renderLiquidGauge(this.props.tags.val_FluidLevel, "ft.", 500, "Level")}
{this.renderRadialGauge(this.props.tags.val_Flowmeter_BarrelsPerDay, "BPD", 5000, "Flow Rate")}
{this.renderRadialGauge(this.props.tags.val_TubingPressure, "PSI", 400, "Tubing Pressure")}
</div>
<FlexibleWidthXYPlot
height={300}
xType="time"
@@ -35,26 +208,38 @@ class Main extends Component {
<XAxis />
<YAxis />
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_IntakePressure)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_IntakePressure)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_Flowmeter)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_Flowmeter)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.val_FluidLevel)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_FluidLevel)}
/>
<LineSeries
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_IntakeTemperature)}
/>
<LineSeries
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_TubingPressure)}
/>
</FlexibleWidthXYPlot>
<DiscreteColorLegend
items={[
{title: "Intake Pressure"},
{title: "Flowmeter"},
{title: "Fluid Level"}
{title: "Fluid Level"},
{title: "Intake Temp"},
{title: "Tubing Pressure"}
]}
orientation="horizontal"
/>
<hr />
<h3>VFD Data</h3>
<div className="row" style={{marginBottom: "20px"}}>
{this.renderRadialGauge(this.props.tags.VFD_OutCurrent, "A.", 100, "Current")}
{this.renderRadialGauge(this.props.tags.VFD_SpeedFdbk, "Hz", 60, "Frequency")}
</div>
<FlexibleWidthXYPlot
height={300}
xType="time"
@@ -64,16 +249,16 @@ class Main extends Component {
<XAxis />
<YAxis />
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutCurrent)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_OutCurrent)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_SpeedFdbk)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_SpeedFdbk)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutPower)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_OutPower)}
/>
<LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_Temp)}
data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_Temp)}
/>
</FlexibleWidthXYPlot>
<DiscreteColorLegend
@@ -91,10 +276,18 @@ class Main extends Component {
}
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
tags: state.tags,
tagHistory: state.tagHistory
tagHistory: state.tagHistory,
plc: state.plc
};
}

View File

@@ -65,6 +65,13 @@ class Permissives extends Component {
}
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
tags: state.tags

View File

@@ -3,6 +3,8 @@ import { connect } from "react-redux";
import _ from "lodash";
import { setPlcIpAddress, ipcPlcInitializeSend } from "../actions/actions_plc";
import { storeNewTag, deleteTag } from "../actions/actions_tags";
import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faTimesSquare } from "@fortawesome/fontawesome-pro-regular";
class Settings extends Component {
constructor(props){
@@ -17,30 +19,19 @@ class Settings extends Component {
getTagList = () => {
const { tags } = this.props;
let tableMiddle = _.map(tags, (tag) => {
return (<tr key={tag.name}>
<td>{tag.name}</td>
<td>
return _.map(tags, (tag) => {
return (
<li key={tag.name} className="list-group-item">
{tag.name}
<button
className="btn red"
className="btn btn-outline-danger float-right"
onClick={(e) => this.onDeleteClick(e, tag.name)}
><i className="material-icons">clear</i></button></td>
</tr>);
>
<FontAwesomeIcon icon={faTimesSquare} />
</button>
</li>
);
});
return (
<table>
<thead>
<tr>
<th>Tag</th>
<th></th>
</tr>
</thead>
<tbody>
{tableMiddle}
</tbody>
</table>
);
}
componentWillMount(){
@@ -76,75 +67,70 @@ class Settings extends Component {
}
onSave = (e) => {
console.log(this.props);
e.preventDefault();
this.props.ipcPlcInitializeSend(this.props.plc.ipAddress, this.props.tags);
this.props.history.push("/");
}
render() {
const ipAddressBtnClass = ((this.state.ipAddress === this.props.plc.ipAddress) || (this.state.ipAddress.length === 0)) ? "btn disabled right" : "btn right";
const initializeBtnClass = (this.props.plc.ipAddress && _.map(this.props.tags, (t)=>t).length > 0) ? "btn" : "btn disabled";
const ipAddressBtnClass = ((this.state.ipAddress === this.props.plc.ipAddress) || (this.state.ipAddress.length === 0)) ? "btn btn-primary disabled" : "btn btn-primary";
const initializeBtnClass = (this.props.plc.ipAddress && _.map(this.props.tags, (t)=>t).length > 0) ? "btn btn-success" : "btn sbtn-success disabled";
return (
<div style={styles.container}>
<ul className="collection with-header">
<li className="collection-header">
Settings
</li>
<form>
<li className="collection-item">
<p>IP Address</p>
<input
value={this.state.ipAddress}
placeholder="PLC IP Address"
onChange={this.onIpAddressInputChange}
/>
<button
className={ipAddressBtnClass}
onClick={(e) => this.sendIpAddress(e)}>
Set IP Address
</button>
</li>
</form>
<div className="container">
<h1>Settings</h1>
<form className="form-inline mb-2">
<div className="form-group">
<label htmlFor="ipAddress">IP Address</label>
<input
id="ipAddress"
className="form-control "
value={this.state.ipAddress}
placeholder="PLC IP Address"
onChange={this.onIpAddressInputChange}
/>
<button
className={ipAddressBtnClass}
onClick={(e) => this.sendIpAddress(e)}>
Set IP Address
</button>
</div>
</form>
<form>
<li className="collection-item">
<h4>Tag List</h4>
{this.getTagList()}
<input
value={this.state.newTag}
onChange={this.onNewTagChange}
placeholder="New Tag Name..."
/>
<button
className="btn"
onClick={(e) => this.onNewTagSubmit(e)}
>Add Tag</button>
</li>
<li className="collection-item right">
<button
className={initializeBtnClass}
onClick={(e) => this.onSave(e)}
>Save</button>
</li>
</form>
<h4>Tag List</h4>
<ul className="list-group">
{this.getTagList()}
</ul>
<form>
<input
value={this.state.newTag}
onChange={this.onNewTagChange}
placeholder="New Tag Name..."
/>
<button
className="btn btn-success float-right"
onClick={(e) => this.onNewTagSubmit(e)}
>Add Tag</button>
<button
className={initializeBtnClass}
onClick={(e) => this.onSave(e)}
>Save</button>
</form>
</div>
);
}
}
const styles = {
container: {
display: "flex",
flexDirection: "column"
},
pointer: {
cursor: "pointer"
}
};
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return{
tags: state.tags,

View File

@@ -120,6 +120,13 @@ const styles = {
}
};
/**
* Map Redux state to React props
*
* @param {Object} state
*
* @returns {Object} mapped state
*/
function mapStateToProps(state){
return {
tags: state.tags,

View File

@@ -51,3 +51,18 @@
:global .control-button {
width: 95%;
}
:global .alarms-active {
animation: pulse 0.5s infinite;
}
@keyframes pulse {
0% {
background-color: #FFFFFF !important;
color: #dc3545 !important;
}
100% {
background-color: #dc3545 !important;
color: #FFFFFF !important;
}
}

View File

@@ -3,11 +3,15 @@ import { combineReducers } from "redux";
import TagsReducer from "./reducer_tags";
import PlcReducer from "./reducer_plc";
import TagHistoryReducer from "./reducer_taghistory";
import AlarmsReducer from "./reducer_alarm";
import EventsReducer from "./reducer_events";
const rootReducer = combineReducers({
tags: TagsReducer,
tagHistory: TagHistoryReducer,
plc: PlcReducer
plc: PlcReducer,
alarms: AlarmsReducer,
events: EventsReducer
});

View File

@@ -0,0 +1,33 @@
import _ from "lodash";
import { IPC_TAGUPDATE } from "../actions/actions_tags";
import {event_tags as eventTags} from "../../../tagList.json";
export default function(state = [], action){
switch (action.type) {
case IPC_TAGUPDATE:
// console.log(eventTags);
const { name, value } = action.payload;
const alarmTagList = _.map(_.filter(eventTags, (tag) => {
return tag.eventType === "alarm";
}), (ftag) => {
return ftag.tag;
});
if (alarmTagList.includes(name)){
if(value){
return _.uniq(_.concat(state, name));
} else {
const newActiveState = _.filter(state, (tag) => {
return tag !== name;
});
return newActiveState;
}
} else {
return state;
}
default:
return state;
}
}

View File

@@ -0,0 +1,28 @@
import _ from "lodash";
import { IPC_TAGUPDATE } from "../actions/actions_tags";
import {event_tags as eventTags} from "../../../tagList.json";
export default function(state = [], action){
switch (action.type) {
case IPC_TAGUPDATE:
const { name, value } = action.payload;
const eventTagList = _.map(eventTags, (ftag) => {
return ftag.tag;
});
if (eventTagList.includes(name)){
if(value){
const thisEvent = _.find(eventTags, (tag) => {
return tag.tag === name;
});
const newHistory = _.concat([{...thisEvent, timestamp: new Date()}], state);
return newHistory;
}
}
return state;
default:
return state;
}
}

View File

@@ -1,4 +1,4 @@
import { SET_PLC_IPADDRESS, PLC_DATA_RECEIVED } from "../actions/actions_plc";
import { SET_PLC_IPADDRESS, PLC_DATA_RECEIVED, PLC_ERROR_RECEIVED } from "../actions/actions_plc";
export default function(state = {}, action){
switch(action.type){
@@ -6,9 +6,11 @@ export default function(state = {}, action){
return { ...state, ipAddress: action.payload };
case PLC_DATA_RECEIVED:
console.log(action.payload);
return { ...state, ...action.payload };
case PLC_ERROR_RECEIVED:
return { ...state, error: action.payload };
default:
return state;
}

View File

@@ -1,6 +1,6 @@
import _ from "lodash";
import { IPC_TAGUPDATE } from "../actions/actions_tags";
import { historyTags } from "../renderer_process";
import {history_tags as historyTags} from "../../../tagList.json";
export default function(state = {}, action){

View File

@@ -14,15 +14,17 @@ import Header from "./components/Header";
import Permissives from "./components/Permissives";
import Main from "./components/Main";
import Controls from "./components/Controls";
import EventLog from "./components/EventLog";
import { ipcTagUpdate } from "./actions/actions_tags";
import { ipcPlcDetailsReceived } from "./actions/actions_plc";
import { ipcPlcDetailsReceived, ipcPlcErrorReceived } from "./actions/actions_plc";
export const { history_tags: historyTags } = require("../../tagList.json");
export const { history_tags: historyTags, event_tags: eventTags } = require("../../tagList.json");
const ipc = createIpc({
"tag:valueupdate": ipcTagUpdate,
"plc:connected": ipcPlcDetailsReceived
"plc:connected": ipcPlcDetailsReceived,
"plc:error": ipcPlcErrorReceived
});
const createStoreWithMiddleware = applyMiddleware(ipc)(createStore);
@@ -37,6 +39,7 @@ ReactDOM.render(
<Route path="/permissives" component={Permissives} />
<Route path="/alltags" component={TagsIndex} />
<Route path="/controls" component={Controls} />
<Route path="/events" component={EventLog} />
<Route path="/" component={Main} />
</Switch>
</div>