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": {
"browser": true,
"es6": true,
"node": true
"node": true,
"jest": true
},
"extends": [
"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%;
}
.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>

View File

@@ -4,18 +4,26 @@ const { Controller, Tag } = require("ethernet-ip");
const _ = require("lodash");
const { app, BrowserWindow, ipcMain } = electron;
const tagList = require("./tagList.json");
// To avoid being garbage collected
let mainWindow;
let PLC;
const tagList = require("./tagList.json");
app.on("ready", () => {
mainWindow = new BrowserWindow({});
mainWindow = new BrowserWindow({
width: 1000,
height: 1000
});
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};
mainWindow.webContents.send("plc:connected", properties);
PLC.scan().catch((err) => {
mainWindow.webContents.send("plc:error", err.message);
console.log(err);
});
}).catch((err) => {
mainWindow.webContents.send("plc:error", err.message);
console.log(err);
});
@@ -62,6 +72,7 @@ function initPLC(ipAddress, tagList){
}
ipcMain.on("plc:initialize", (event, ipAddress, tagList) =>{
// console.log("plc:initialize", ipAddress, tagList);
initPLC(ipAddress, tagList);

View File

@@ -9,7 +9,19 @@
"serve": "electron .",
"start": "npm-run-all --parallel wpackserve serve",
"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",
"repository": "HenryPump/MaxWaterSystem-Electron",
@@ -28,20 +40,29 @@
"eslint": "^4.19.1",
"eslint-plugin-react": "^7.7.0",
"file-loader": "^1.1.10",
"jest": "^22.4.3",
"mini-css-extract-plugin": "^0.4.0",
"npm-run-all": "^4.1.2",
"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": {
"@fortawesome/fontawesome": "^1.1.5",
"@fortawesome/fontawesome-pro-regular": "^5.0.9",
"@fortawesome/fontawesome-pro-webfonts": "^1.0.5",
"@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",
"lodash": "^4.17.5",
"react": "^16.2.0",
"react-canvas-gauges": "^1.2.1",
"react-dom": "^16.2.0",
"react-event-timeline": "^1.5.1",
"react-liquid-gauge": "^1.2.4",
"react-redux": "^5.0.7",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
@@ -49,6 +70,7 @@
"react-vis": "^1.9.2",
"redux": "^3.7.2",
"redux-electron-ipc": "^1.1.12",
"redux-persist": "^5.9.1",
"victory": "^0.25.7"
},
"build": {

View File

@@ -90,16 +90,73 @@
"VFD_SpeedRef",
"VFD_Temp"
],
"alarm_tags":
"event_tags":
[
"alarm_ESTOP",
"alarm_Flowmeter",
"alarm_FluidLevel",
"alarm_IntakePressure",
"alarm_IntakeTemperature",
"alarm_Lockout",
"alarm_MinSpeed",
"alarm_TubingPressure",
"alarm_VFD"
{
"tag": "alarm_ESTOP",
"text": "E-Stop has been pressed",
"valueTag": "",
"eventType": "alarm"
},
{
"tag": "alarm_Flowmeter",
"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"
}
]
}