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

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["react", "es2015", "stage-1"]
}

View File

@@ -2,7 +2,8 @@ module.exports = {
"env": { "env": {
"browser": true, "browser": true,
"es6": true, "es6": true,
"node": true "node": true,
"jest": true
}, },
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",

2
.travis.yml Normal file
View File

@@ -0,0 +1,2 @@
language: node_js
node_js: "node"

4
__mock__/electron.js Normal file
View File

@@ -0,0 +1,4 @@
export const ipcRenderer = {
on: jest.fn(),
send: jest.fn()
};

View File

@@ -0,0 +1,89 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { setPlcIpAddress, SET_PLC_IPADDRESS, ipcPlcInitializeSend, INITIALIZE_PLC, ipcPlcDetailsReceived, PLC_DATA_RECEIVED, ipcPlcErrorReceived, PLC_ERROR_RECEIVED } from "../../app/src/actions/actions_plc";
import { ipcRenderer } from "electron";
configure({ adapter: new Adapter() });
describe("actions_plc", () => {
describe("setPlcIpAddress", () => {
let action;
beforeEach(() => {
action = setPlcIpAddress("192.168.1.10");
});
it("has the correct type", () => {
expect(action.type).toEqual(SET_PLC_IPADDRESS);
});
it("has the correct payload", () => {
expect(action.payload).toEqual("192.168.1.10");
});
});
describe("ipcPlcInitializeSend", () => {
let action;
const tagList = {
test: { name: "test", value: 100.0 }
};
beforeEach(() => {
action = ipcPlcInitializeSend("192.168.1.10", tagList);
});
it("has the correct type", () => {
expect(action.type).toEqual(INITIALIZE_PLC);
});
it("has the correct payload", () => {
expect(action.payload).toBeTruthy();
});
it("triggers the ipcRenderer.send function", () => {
expect(ipcRenderer.send).toHaveBeenCalled();
});
});
describe("ipcPlcDetailsReceived", () => {
let action;
const plcData = {
ipAddress: "192.168.1.10",
details: {
name: "CLX"
}
};
beforeEach(() => {
action = ipcPlcDetailsReceived(undefined, plcData);
});
it("has the correct type", () => {
expect(action.type).toEqual(PLC_DATA_RECEIVED);
});
it("has the correct payload", () => {
expect(action.payload).toEqual({
ipAddress: "192.168.1.10",
details: {
name: "CLX"
}
});
});
});
describe("ipcPlcErrorReceived", () => {
let action;
beforeEach(() => {
action = ipcPlcErrorReceived(undefined, "ERROR MESSAGE!");
});
it("has the correct type", () => {
expect(action.type).toEqual(PLC_ERROR_RECEIVED);
});
it("has the correct payload", () => {
expect(action.payload).toEqual("ERROR MESSAGE!");
});
});
});

View File

@@ -0,0 +1,112 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { ipcRenderer } from "electron";
import { ipcTagUpdate, IPC_TAGSYNC, IPC_TAGUPDATE, ipcTagSync, storeNewTag, STORE_NEW_TAG, deleteTag, DELETE_TAG, writeTag, WRITE_TAG } from "../../app/src/actions/actions_tags";
configure({ adapter: new Adapter() });
describe("actions_tags", () => {
describe("ipcTagUpdate", () => {
let action;
const sampleTag = { state: {
tag: {
name: "test",
value: 100.0
}
}};
beforeEach(() => {
action = ipcTagUpdate(undefined, sampleTag);
});
it("has the correct type", () => {
expect(action.type).toEqual(IPC_TAGUPDATE);
});
it("has the correct payload", () => {
expect(action.payload).toEqual({ name: "test", value: 100.0 });
});
});
describe("ipcTagSync", () => {
let action;
const sampleTag = { test: {
name: "test",
value: 100.0
}};
beforeEach(() => {
action = ipcTagSync("192.168.1.10", sampleTag);
});
it("has the correct type", () => {
expect(action.type).toEqual(IPC_TAGSYNC);
});
it("has the correct payload", () => {
expect(action.payload).toBeTruthy();
});
it("should execute the ipcRenderer.send function", () => {
expect(ipcRenderer.send).toHaveBeenCalled();
});
});
describe("storeNewTag", () => {
let action;
const sampleTag = {
name: "test",
value: 100.0
};
beforeEach(() => {
action = storeNewTag(sampleTag);
});
it("has the correct type", () => {
expect(action.type).toEqual(STORE_NEW_TAG);
});
it("has the correct payload", () => {
expect(action.payload).toEqual({ name: "test", value: 100.0 });
});
});
describe("deleteTag", () => {
let action;
beforeEach(() => {
action = deleteTag("test");
});
it("has the correct type", () => {
expect(action.type).toEqual(DELETE_TAG);
});
it("has the correct payload", () => {
expect(action.payload).toEqual("test");
});
});
describe("writeTag", () => {
let action;
beforeEach(() => {
action = writeTag("test", 100.0);
});
it("has the correct type", () => {
expect(action.type).toEqual(WRITE_TAG);
});
it("has the correct payload", () => {
expect(action.payload).toBeTruthy();
});
it("should execute the ipcRenderer.send function", () => {
expect(ipcRenderer.send).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,44 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });
import AlarmReducer from "../../app/src/reducers/reducer_alarm";
import { IPC_TAGUPDATE } from "../../app/src/actions/actions_tags";
describe("reducer_alarm", () => {
it("should not change state on unused type", () => {
expect(AlarmReducer(["test", "test"], "test")).toEqual(["test", "test"]);
});
it("should return a default empty object", () => {
expect(AlarmReducer(undefined, "test")).toEqual([]);
});
describe("IPC_TAGUPDATE", () => {
let action = {
type: IPC_TAGUPDATE,
payload: ""
};
it("should not change active alarms if tag not in alarm list", () => {
action.payload = { name: "test", value: false };
expect(AlarmReducer([], action)).toEqual([]);
});
it("should add the tag to active alarms if alarm tag value is true", () => {
action.payload = { name: "alarm_ESTOP", value: true };
expect(AlarmReducer([], action)).toEqual(["alarm_ESTOP"]);
});
it("should not change active alarms if alarm tag value is false", () => {
action.payload = { name: "alarm_ESTOP", value: false };
expect(AlarmReducer(["test"], action)).toEqual(["test"]);
});
it("should remove the event from the list if alarm tag value is false", () => {
action.payload = { name: "alarm_ESTOP", value: false };
expect(AlarmReducer(["alarm_ESTOP"], action)).toEqual([]);
});
});
});

View File

@@ -0,0 +1,39 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });
import EventsReducer from "../../app/src/reducers/reducer_events";
import { IPC_TAGUPDATE } from "../../app/src/actions/actions_tags";
describe("reducer_events", () => {
it("should not change state on unused type", () => {
expect(EventsReducer(["test", "test"], "test")).toEqual(["test", "test"]);
});
it("should return a default empty object", () => {
expect(EventsReducer(undefined, "test")).toEqual([]);
});
describe("IPC_TAGUPDATE", () => {
let action = {
type: IPC_TAGUPDATE,
payload: ""
};
it("should add an event to the event log for an event tag true", () => {
action.payload = { name: "cmd_Start", value: true };
const newState = EventsReducer([], action);
expect(newState[0].tag).toEqual("cmd_Start");
});
it("should return unaltered event log if not in event log", () => {
action.payload = { name: "test", value: true };
expect(EventsReducer([], action)).toEqual([]);
});
it("should return unaltered event log if event tag value false", () => {
action.payload = { name: "cmd_Start", value: false };
expect(EventsReducer([], action)).toEqual([]);
});
});
});

View File

@@ -0,0 +1,52 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });
import PlcReducer from "../../app/src/reducers/reducer_plc";
import { SET_PLC_IPADDRESS, PLC_DATA_RECEIVED, PLC_ERROR_RECEIVED } from "../../app/src/actions/actions_plc";
describe("PlcReducer", () => {
it("should not change state on unused type", () => {
expect(PlcReducer({test: "test"}, {})).toEqual({test: "test"});
});
it("should return a default empty object", () => {
expect(PlcReducer(undefined, "test")).toEqual({});
});
describe("SET_PLC_IPADDRESS", ()=>{
const action = {
type: SET_PLC_IPADDRESS,
payload: "192.168.1.10"
};
it("should add ip address to state", ()=> {
const state = PlcReducer({}, action);
expect(state.ipAddress).toEqual(action.payload);
});
});
describe("PLC_DATA_RECEIVED", ()=>{
const action = {
type: PLC_DATA_RECEIVED,
payload: {name: "test"}
};
it("should add details to state", ()=> {
const state = PlcReducer({}, action);
expect(state).toEqual(action.payload);
});
});
describe("PLC_ERROR_RECEIVED", ()=>{
const action = {
type: PLC_ERROR_RECEIVED,
payload: "PLC ERROR!"
};
it("should add details to state", ()=> {
const state = PlcReducer({}, action);
expect(state.error).toEqual("PLC ERROR!");
});
});
});

View File

@@ -0,0 +1,41 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import _ from "lodash";
configure({ adapter: new Adapter() });
import TagHistoryReducer from "../../app/src/reducers/reducer_taghistory";
import { IPC_TAGUPDATE } from "../../app/src/actions/actions_tags";
describe("PlcReducer", () => {
it("should not change state on unused type", () => {
expect(TagHistoryReducer({test: "test"}, "test")).toEqual({test: "test"});
});
it("should return a default empty object", () => {
expect(TagHistoryReducer(undefined, "test")).toEqual({});
});
describe("IPC_TAGUPDATE", () => {
const action = {
type: IPC_TAGUPDATE,
payload: ""
};
it("should add a history tag to an empty state", () => {
action.payload = { name: "val_IntakePressure", value: 100 };
expect(_.map(TagHistoryReducer({}, action), (x) => x)).toHaveLength(1);
});
it("should add another value to an existing history state", () => {
action.payload = { name: "val_IntakePressure", value: 100 };
const stateAfterAdd = TagHistoryReducer({}, action);
expect(_.map(TagHistoryReducer(stateAfterAdd, action), (x) => x)[0]).toHaveLength(2);
});
it("should not add to the history if the tag is not a historical tag", () => {
action.payload = { name: "test", value: 100 };
expect(_.map(TagHistoryReducer({}, action), (x) => x)).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,63 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });
import TagsReducer from "../../app/src/reducers/reducer_tags";
import { IPC_TAGUPDATE, STORE_NEW_TAG, DELETE_TAG } from "../../app/src/actions/actions_tags";
describe("PlcReducer", () => {
it("should not change state on unused type", () => {
expect(TagsReducer({test: "test"}, "test")).toEqual({test: "test"});
});
it("should return a default empty object", () => {
expect(TagsReducer(undefined, "test")).toEqual({});
});
describe("IPC_TAGUPDATE", () => {
const action = {
type: IPC_TAGUPDATE,
payload: { name: "test", value: 111.111 }
};
it("should store a new value for a new tag", () => {
const newState = TagsReducer({}, action);
expect(newState).toEqual({ test: { name: "test", value: 111.111 }});
});
it("should store a new value for an existing tag", () => {
const existingState = { test: { name: "test", value: 0.00 }};
const newState = TagsReducer(existingState, action);
expect(newState).toEqual({test: { name: "test", value: 111.111 }});
});
});
describe("STORE_NEW_TAG", () => {
const action = {
type: STORE_NEW_TAG,
payload: "test"
};
it("should create a new object for a new tag", () => {
const newState = TagsReducer({}, action);
expect(newState).toEqual({ test: { name: "test" }});
});
});
describe("DELETE_TAG", () => {
const action = {
type: DELETE_TAG,
payload: "test"
};
it("should remove the object in state with the key in the payload", () => {
const initialState = {
test: { name: "test", value: 10.0 },
remain: { name: "remain", value: 1000 }
};
const newState = TagsReducer(initialState, action);
expect(newState).toEqual({ remain: { name: "remain", value: 1000 } });
});
});
});

File diff suppressed because one or more lines are too long

View File

@@ -54,3 +54,17 @@ body {
width: 95%; 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 SET_PLC_IPADDRESS = "SET_PLC_IPADDRESS";
export const INITIALIZE_PLC = "INITIALIZE_PLC"; export const INITIALIZE_PLC = "INITIALIZE_PLC";
export const PLC_DATA_RECEIVED = "PLC_DATA_RECEIVED"; export const PLC_DATA_RECEIVED = "PLC_DATA_RECEIVED";
export const PLC_ERROR_RECEIVED = "PLC_ERROR_RECEIVED";
export function setPlcIpAddress(ipAddress){ export function setPlcIpAddress(ipAddress){
@@ -21,10 +22,16 @@ export function ipcPlcInitializeSend(ipAddress, tagList){
} }
export function ipcPlcDetailsReceived(event, plcData){ export function ipcPlcDetailsReceived(event, plcData){
console.log("action creator got PLC data", plcData);
return { return {
type: PLC_DATA_RECEIVED, type: PLC_DATA_RECEIVED,
payload: plcData 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){ function mapStateToProps(state){
return { return {
tags: state.tags 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 { Link } from "react-router-dom";
import FontAwesomeIcon from "@fortawesome/react-fontawesome"; import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faTint, faCog } from "@fortawesome/fontawesome-pro-regular"; import { faTint, faCog } from "@fortawesome/fontawesome-pro-regular";
/**
* Class to render Header navigation bar
*
* @extends React.Component
*/
class Header extends Component { class Header extends Component {
/**
* renders the running state indicator button
*
* @returns {button} inactive running state button
*/
renderRunningState(){ renderRunningState(){
if(!this.props.tags ){ if(!this.props.tags ){
return <span></span>; 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(){ render(){
return ( return (
<nav className="navbar navbar-light navbar-expand-sm bg-light"> <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> <Link className="nav-item nav-link" to="/alltags">All Tags</Link>
</div> </div>
<div className="navbar-nav ml-auto"> <div className="navbar-nav ml-auto">
<span className="navbar-text">{this.renderAlarmButton()}</span>
<span className="navbar-text">{this.renderRunningState()}</span> <span className="navbar-text">{this.renderRunningState()}</span>
<Link className="nav-item nav-link" to="/settings" id="settings-button"><FontAwesomeIcon icon={faCog} /></Link> <Link className="nav-item nav-link" to="/settings" id="settings-button"><FontAwesomeIcon icon={faCog} /></Link>
</div> </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){ function mapStateToProps(state){
return { 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 _ from "lodash";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { FlexibleWidthXYPlot, LineSeries, VerticalGridLines, HorizontalGridLines, XAxis, YAxis, DiscreteColorLegend } from "react-vis"; 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 { 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 _.map(values, (val) => {
return {x: val.timestamp, y: val.value}; 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(){ render(){
if (!this.props.tagHistory){ if (!this.props.tagHistory){
return( 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 ( return (
<div className="container"> <div className="container">
<h3>Process Values</h3> <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 <FlexibleWidthXYPlot
height={300} height={300}
xType="time" xType="time"
@@ -35,26 +208,38 @@ class Main extends Component {
<XAxis /> <XAxis />
<YAxis /> <YAxis />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.val_IntakePressure)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_IntakePressure)}
/> />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.val_Flowmeter)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.val_Flowmeter)}
/> />
<LineSeries <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> </FlexibleWidthXYPlot>
<DiscreteColorLegend <DiscreteColorLegend
items={[ items={[
{title: "Intake Pressure"}, {title: "Intake Pressure"},
{title: "Flowmeter"}, {title: "Flowmeter"},
{title: "Fluid Level"} {title: "Fluid Level"},
{title: "Intake Temp"},
{title: "Tubing Pressure"}
]} ]}
orientation="horizontal" orientation="horizontal"
/> />
<hr /> <hr />
<h3>VFD Data</h3> <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 <FlexibleWidthXYPlot
height={300} height={300}
xType="time" xType="time"
@@ -64,16 +249,16 @@ class Main extends Component {
<XAxis /> <XAxis />
<YAxis /> <YAxis />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutCurrent)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_OutCurrent)}
/> />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_SpeedFdbk)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_SpeedFdbk)}
/> />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_OutPower)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_OutPower)}
/> />
<LineSeries <LineSeries
data={this.getChartValues(this.props.tagHistory.VFD_Temp)} data={this.mapTimestampAndValuePropToXY(this.props.tagHistory.VFD_Temp)}
/> />
</FlexibleWidthXYPlot> </FlexibleWidthXYPlot>
<DiscreteColorLegend <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){ function mapStateToProps(state){
return { return {
tags: state.tags, 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){ function mapStateToProps(state){
return { return {
tags: state.tags tags: state.tags

View File

@@ -3,6 +3,8 @@ import { connect } from "react-redux";
import _ from "lodash"; import _ from "lodash";
import { setPlcIpAddress, ipcPlcInitializeSend } from "../actions/actions_plc"; import { setPlcIpAddress, ipcPlcInitializeSend } from "../actions/actions_plc";
import { storeNewTag, deleteTag } from "../actions/actions_tags"; import { storeNewTag, deleteTag } from "../actions/actions_tags";
import FontAwesomeIcon from "@fortawesome/react-fontawesome";
import { faTimesSquare } from "@fortawesome/fontawesome-pro-regular";
class Settings extends Component { class Settings extends Component {
constructor(props){ constructor(props){
@@ -17,30 +19,19 @@ class Settings extends Component {
getTagList = () => { getTagList = () => {
const { tags } = this.props; const { tags } = this.props;
let tableMiddle = _.map(tags, (tag) => { return _.map(tags, (tag) => {
return (<tr key={tag.name}> return (
<td>{tag.name}</td> <li key={tag.name} className="list-group-item">
<td> {tag.name}
<button <button
className="btn red" className="btn btn-outline-danger float-right"
onClick={(e) => this.onDeleteClick(e, tag.name)} 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(){ componentWillMount(){
@@ -76,75 +67,70 @@ class Settings extends Component {
} }
onSave = (e) => { onSave = (e) => {
console.log(this.props);
e.preventDefault(); e.preventDefault();
this.props.ipcPlcInitializeSend(this.props.plc.ipAddress, this.props.tags); this.props.ipcPlcInitializeSend(this.props.plc.ipAddress, this.props.tags);
this.props.history.push("/"); this.props.history.push("/");
} }
render() { render() {
const ipAddressBtnClass = ((this.state.ipAddress === this.props.plc.ipAddress) || (this.state.ipAddress.length === 0)) ? "btn disabled right" : "btn right"; 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 disabled"; const initializeBtnClass = (this.props.plc.ipAddress && _.map(this.props.tags, (t)=>t).length > 0) ? "btn btn-success" : "btn sbtn-success disabled";
return ( return (
<div style={styles.container}> <div className="container">
<ul className="collection with-header"> <h1>Settings</h1>
<li className="collection-header"> <form className="form-inline mb-2">
Settings <div className="form-group">
</li> <label htmlFor="ipAddress">IP Address</label>
<form> <input
<li className="collection-item"> id="ipAddress"
<p>IP Address</p> className="form-control "
<input value={this.state.ipAddress}
value={this.state.ipAddress} placeholder="PLC IP Address"
placeholder="PLC IP Address" onChange={this.onIpAddressInputChange}
onChange={this.onIpAddressInputChange} />
/> <button
<button className={ipAddressBtnClass}
className={ipAddressBtnClass} onClick={(e) => this.sendIpAddress(e)}>
onClick={(e) => this.sendIpAddress(e)}> Set IP Address
Set IP Address </button>
</button> </div>
</li> </form>
</form>
<form>
<li className="collection-item"> <h4>Tag List</h4>
<h4>Tag List</h4> <ul className="list-group">
{this.getTagList()} {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>
</ul> </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> </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){ function mapStateToProps(state){
return{ return{
tags: state.tags, 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){ function mapStateToProps(state){
return { return {
tags: state.tags, tags: state.tags,

View File

@@ -51,3 +51,18 @@
:global .control-button { :global .control-button {
width: 95%; 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 TagsReducer from "./reducer_tags";
import PlcReducer from "./reducer_plc"; import PlcReducer from "./reducer_plc";
import TagHistoryReducer from "./reducer_taghistory"; import TagHistoryReducer from "./reducer_taghistory";
import AlarmsReducer from "./reducer_alarm";
import EventsReducer from "./reducer_events";
const rootReducer = combineReducers({ const rootReducer = combineReducers({
tags: TagsReducer, tags: TagsReducer,
tagHistory: TagHistoryReducer, 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){ export default function(state = {}, action){
switch(action.type){ switch(action.type){
@@ -6,9 +6,11 @@ export default function(state = {}, action){
return { ...state, ipAddress: action.payload }; return { ...state, ipAddress: action.payload };
case PLC_DATA_RECEIVED: case PLC_DATA_RECEIVED:
console.log(action.payload);
return { ...state, ...action.payload }; return { ...state, ...action.payload };
case PLC_ERROR_RECEIVED:
return { ...state, error: action.payload };
default: default:
return state; return state;
} }

View File

@@ -1,6 +1,6 @@
import _ from "lodash"; import _ from "lodash";
import { IPC_TAGUPDATE } from "../actions/actions_tags"; 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){ export default function(state = {}, action){

View File

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

View File

@@ -4,18 +4,26 @@ const { Controller, Tag } = require("ethernet-ip");
const _ = require("lodash"); const _ = require("lodash");
const { app, BrowserWindow, ipcMain } = electron; const { app, BrowserWindow, ipcMain } = electron;
const tagList = require("./tagList.json");
// To avoid being garbage collected // To avoid being garbage collected
let mainWindow; let mainWindow;
let PLC; let PLC;
const tagList = require("./tagList.json");
app.on("ready", () => { app.on("ready", () => {
mainWindow = new BrowserWindow({}); mainWindow = new BrowserWindow({
width: 1000,
height: 1000
});
mainWindow.loadURL(`file://${__dirname}/app/index.html`); mainWindow.loadURL(`file://${__dirname}/app/index.html`);
initPLC("10.20.4.36", tagList.scan_list); // Wait for allowing react app to fully load
// before starting to send initalized data.
setTimeout(() => {
initPLC("10.20.4.36", tagList.scan_list);
}, 2000);
}); });
@@ -41,9 +49,11 @@ function initPLC(ipAddress, tagList){
const properties = { ...PLC.properties, ipAddress}; const properties = { ...PLC.properties, ipAddress};
mainWindow.webContents.send("plc:connected", properties); mainWindow.webContents.send("plc:connected", properties);
PLC.scan().catch((err) => { PLC.scan().catch((err) => {
mainWindow.webContents.send("plc:error", err.message);
console.log(err); console.log(err);
}); });
}).catch((err) => { }).catch((err) => {
mainWindow.webContents.send("plc:error", err.message);
console.log(err); console.log(err);
}); });
@@ -62,6 +72,7 @@ function initPLC(ipAddress, tagList){
} }
ipcMain.on("plc:initialize", (event, ipAddress, tagList) =>{ ipcMain.on("plc:initialize", (event, ipAddress, tagList) =>{
// console.log("plc:initialize", ipAddress, tagList); // console.log("plc:initialize", ipAddress, tagList);
initPLC(ipAddress, tagList); initPLC(ipAddress, tagList);

View File

@@ -9,7 +9,19 @@
"serve": "electron .", "serve": "electron .",
"start": "npm-run-all --parallel wpackserve serve", "start": "npm-run-all --parallel wpackserve serve",
"pack": "electron-builder --dir", "pack": "electron-builder --dir",
"dist": "electron-builder -mwl" "dist": "electron-builder -mwl",
"docgen": "jsdoc ./app/src/*/*.js -d ./app/out",
"test": "jest --verbose --coverage",
"test:watch": "npm run test -- --watch"
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(electron)/)"
],
"moduleNameMapper": {
"electron": "<rootDir>/__mock__/electron.js"
},
"testEnvironment": "node"
}, },
"author": "Patrick J. McDonagh", "author": "Patrick J. McDonagh",
"repository": "HenryPump/MaxWaterSystem-Electron", "repository": "HenryPump/MaxWaterSystem-Electron",
@@ -28,20 +40,29 @@
"eslint": "^4.19.1", "eslint": "^4.19.1",
"eslint-plugin-react": "^7.7.0", "eslint-plugin-react": "^7.7.0",
"file-loader": "^1.1.10", "file-loader": "^1.1.10",
"jest": "^22.4.3",
"mini-css-extract-plugin": "^0.4.0", "mini-css-extract-plugin": "^0.4.0",
"npm-run-all": "^4.1.2", "npm-run-all": "^4.1.2",
"webpack": "^4.1.1", "webpack": "^4.1.1",
"webpack-cli": "^2.0.11" "webpack-cli": "^2.0.11",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome": "^1.1.5", "@fortawesome/fontawesome": "^1.1.5",
"@fortawesome/fontawesome-pro-regular": "^5.0.9", "@fortawesome/fontawesome-pro-regular": "^5.0.9",
"@fortawesome/fontawesome-pro-webfonts": "^1.0.5", "@fortawesome/fontawesome-pro-webfonts": "^1.0.5",
"@fortawesome/react-fontawesome": "0.0.18", "@fortawesome/react-fontawesome": "0.0.18",
"d3-color": "^1.0.3",
"d3-interpolate": "^1.1.6",
"esdoc": "^1.0.4",
"ethernet-ip": "^1.1.4", "ethernet-ip": "^1.1.4",
"lodash": "^4.17.5", "lodash": "^4.17.5",
"react": "^16.2.0", "react": "^16.2.0",
"react-canvas-gauges": "^1.2.1",
"react-dom": "^16.2.0", "react-dom": "^16.2.0",
"react-event-timeline": "^1.5.1",
"react-liquid-gauge": "^1.2.4",
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-router": "^4.2.0", "react-router": "^4.2.0",
"react-router-dom": "^4.2.2", "react-router-dom": "^4.2.2",
@@ -49,6 +70,7 @@
"react-vis": "^1.9.2", "react-vis": "^1.9.2",
"redux": "^3.7.2", "redux": "^3.7.2",
"redux-electron-ipc": "^1.1.12", "redux-electron-ipc": "^1.1.12",
"redux-persist": "^5.9.1",
"victory": "^0.25.7" "victory": "^0.25.7"
}, },
"build": { "build": {

View File

@@ -90,16 +90,73 @@
"VFD_SpeedRef", "VFD_SpeedRef",
"VFD_Temp" "VFD_Temp"
], ],
"alarm_tags": "event_tags":
[ [
"alarm_ESTOP", {
"alarm_Flowmeter", "tag": "alarm_ESTOP",
"alarm_FluidLevel", "text": "E-Stop has been pressed",
"alarm_IntakePressure", "valueTag": "",
"alarm_IntakeTemperature", "eventType": "alarm"
"alarm_Lockout", },
"alarm_MinSpeed", {
"alarm_TubingPressure", "tag": "alarm_Flowmeter",
"alarm_VFD" "text": "Flowmeter Alarm",
"valueTag": "val_Flowmeter",
"eventType": "alarm"
},
{
"tag": "alarm_FluidLevel",
"text": "Fluid Level Alarm",
"valueTag": "val_FluidLevel",
"eventType": "alarm"
},
{
"tag": "alarm_IntakePressure",
"text": "Intake Pressure Alarm",
"valueTag": "val_IntakePressure",
"eventType": "alarm"
},
{
"tag": "alarm_IntakeTemperature",
"text": "Intake Temperature Alarm",
"valueTag": "val_IntakeTemperature",
"eventType": "alarm"
},
{
"tag": "alarm_MinSpeed",
"text": "Minimum Speed Alarm",
"valueTag": "VFD_SpeedFdbk",
"eventType": "alarm"
},
{
"tag": "alarm_TubingPressure",
"text": "Tubing Pressure Alarm",
"valueTag": "val_TubingPressure",
"eventType": "alarm"
},
{
"tag": "alarm_VFD",
"text": "VFD Alarm",
"valueTag": "sts_CurrentVFDFaultCode",
"eventType": "alarm"
},
{
"tag": "cmd_Start",
"text": "Start button pressed",
"valueTag": "",
"eventType": "event"
},
{
"tag": "cmd_Stop",
"text": "Stop button pressed",
"valueTag": "",
"eventType": "event"
},
{
"tag": "cmd_Restart",
"text": "System restarted automatically",
"valueTag": "",
"eventType": "event"
}
] ]
} }