diff --git a/.gitignore b/.gitignore index a14aeae56..33cc8e869 100644 --- a/.gitignore +++ b/.gitignore @@ -16,12 +16,15 @@ snprc_ehr/resources/views/HelloApp*.* snprc_ehr/resources/views/helloWorld*.* snprc_ehr/resources/views/NewAnimalPage*.* snprc_ehr/resources/views/BirthRecordReport*.* +snprc_ehr/resources/views/ChipReader*.* snprc_ehr/cmd-here.exe snprc_ehr/resources/referenceStudy/*.xlsx snprc_ehr/resources/web/snprc_ehr/snprcReports.js.gz snprc_ehr/resources/web/snprc_ehr/gen/* snprc_mobile -/snprc_ehr.2020*.zip -/snprc_ehr.2020*.7z -/snprc_ehr/.editorconfig -/snprc_ehr/cmd-here.exe +snprc_ehr.2020*.zip +snprc_ehr.2020*.7z +snprc_ehr/.editorconfig +snprc_ehr/cmd-here.exe +snprc_ehr/resources/referenceStudy/LoadTaqmanData +snprc_ehr/resources/referenceStudy/datasetImport/LoadTaqmanData diff --git a/snprc_ehr/.babelrc b/snprc_ehr/.babelrc index bb9275040..d2bd76310 100644 --- a/snprc_ehr/.babelrc +++ b/snprc_ehr/.babelrc @@ -1,10 +1,11 @@ { "presets":[ - "@babel/preset-env", - "@babel/preset-react" + "@babel/preset-env", + "@babel/preset-react" ], "plugins": [ "transform-class-properties", - "@babel/plugin-proposal-object-rest-spread" + "@babel/plugin-proposal-object-rest-spread", + "@babel/plugin-transform-runtime" ] } \ No newline at end of file diff --git a/snprc_ehr/.eslintrc b/snprc_ehr/.eslintrc index f9a59d4b1..e8ad773a4 100644 --- a/snprc_ehr/.eslintrc +++ b/snprc_ehr/.eslintrc @@ -49,6 +49,7 @@ "error", "as-needed" ], + "lines-between-class-members": ["error", "never"], "react/jsx-filename-extension": 0, "react/sort-comp": 0, "global-require": 0, diff --git a/snprc_ehr/package-lock.json b/snprc_ehr/package-lock.json index 92c79f7f1..49abad24f 100644 --- a/snprc_ehr/package-lock.json +++ b/snprc_ehr/package-lock.json @@ -1204,6 +1204,58 @@ "@babel/helper-plugin-utils": "^7.0.0" } }, + "@babel/plugin-transform-runtime": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.11.0.tgz", + "integrity": "sha512-LFEsP+t3wkYBlis8w6/kmnd6Kb1dxTd+wGJ8MlxTGzQo//ehtqlVL4S9DNUa53+dtPSQobN2CXx4d81FqC58cw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.10.4", + "@babel/helper-plugin-utils": "^7.10.4", + "resolve": "^1.8.1", + "semver": "^5.5.1" + }, + "dependencies": { + "@babel/helper-module-imports": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz", + "integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz", + "integrity": "sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/types": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.0.tgz", + "integrity": "sha512-O53yME4ZZI0jO1EVGtF1ePGl0LHirG4P1ibcD80XyzZcKhcMFeCXmh4Xb1ifGBIV233Qg12x4rBfQgA+tmOukA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + } + } + }, "@babel/plugin-transform-shorthand-properties": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz", diff --git a/snprc_ehr/package.json b/snprc_ehr/package.json index b2b24d3cc..4003610ff 100644 --- a/snprc_ehr/package.json +++ b/snprc_ehr/package.json @@ -67,6 +67,7 @@ "@babel/core": "^7.10.2", "@babel/plugin-proposal-class-properties": "^7.10.1", "@babel/plugin-proposal-object-rest-spread": "^7.10.1", + "@babel/plugin-transform-runtime": "^7.11.0", "@babel/polyfill": "7.4.4", "@babel/preset-env": "7.4.5", "@babel/preset-react": "7.0.0", diff --git a/snprc_ehr/resources/queries/study/idHistory.query.xml b/snprc_ehr/resources/queries/study/idHistory.query.xml index f5b65e48b..34a87c16e 100644 --- a/snprc_ehr/resources/queries/study/idHistory.query.xml +++ b/snprc_ehr/resources/queries/study/idHistory.query.xml @@ -2,11 +2,6 @@ - - - - - TxBiomed ID @@ -57,4 +52,4 @@
- \ No newline at end of file + diff --git a/snprc_ehr/resources/views/begin.html b/snprc_ehr/resources/views/begin.html index e65e46425..e34c976bd 100644 --- a/snprc_ehr/resources/views/begin.html +++ b/snprc_ehr/resources/views/begin.html @@ -137,6 +137,7 @@ }, items: [ { name: 'Compare Lists of Animals', url: '<%=contextPath%>/ehr' + ctx['EHRStudyContainer'] + '/utilities.view?' }, + {name: 'Chip Reader',url: '<%=contextPath%>/snprc_ehr' + ctx['EHRStudyContainer'] + '/ChipReader.view?'} //{name: 'Drug Formulary', url: '<%=contextPath%>/query' + ctx['EHRStudyContainer'] + '/executeQuery.view?schemaName=ehr_lookups&query.queryName=drug_defaults'}, //{name: 'Procedure List', url: '<%=contextPath%>/query' + ctx['EHRStudyContainer'] + '/executeQuery.view?schemaName=ehr_lookups&query.queryName=procedures&query.viewName=Active Procedures'}, //{name: 'Search Center SNOMED Codes', url: '<%=contextPath%>/query' + ctx['EHRStudyContainer'] + '/executeQuery.view?schemaName=ehr_lookups&query.queryName=snomed'}, diff --git a/snprc_ehr/src/client/ChipReader/ChipReader.jsx b/snprc_ehr/src/client/ChipReader/ChipReader.jsx new file mode 100644 index 000000000..2704ee8bd --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/ChipReader.jsx @@ -0,0 +1,145 @@ +/* eslint-disable no-alert */ + +import React from 'react' +import { LoadingSpinner } from '@labkey/components' +import './styles/chipReader.scss' +import ChipReaderState from './constants/chipReaderState' +import constants from './constants/index' +import InfoPanel from '../Shared/components/InfoPanel' +import ChipDataPanel from './components/ChipDataPanel' +import SummaryGridPanel from './components/SummaryGridPanel' +import fetchAnimalId from './api/fetchAnimalId' +import { close } from './services/serialService' + +export default class ChipReader extends React.Component { + state = new ChipReaderState(); + notSupportedMessage = constants.notSupportedMessage + debug = constants.debug; + isSerialSupported = ('serial' in navigator) || this.props.debug + previousChipId = undefined + componentDidMount() { + window.addEventListener('beforeunload', this.beforeunload.bind(this)) + + this.setState(prevState => ( + { + ...prevState, + serialOptions: constants.defaultSerialOptions, + isLoading: false + } + )) + } + componentWillUnmount() { + window.removeEventListener('beforeunload', this.beforeunload.bind(this)) + } + beforeunload(e) { + if (this.state.connection) { + close(this.state.connection) + e.preventDefault() + e.returnValue = true + } + } + handleSetConnection = connection => { + this.setState(prevState => ( + { + ...prevState, + connection + } + )) + } + handleDataChange = async value => { + const data = value || { chipId: undefined, animalId: undefined, temperature: undefined } + + if (!data.chipId || data.chipId === this.previousChipId) return + + // console.log(`chipId = ${data.chipId}`) + this.previousChipId = data.chipId + + if (data.chipId && !data.animalId) { + data.animalId = await fetchAnimalId(data.chipId).catch(error => { + this.setState(prevState => ( + { + ...prevState, + errorMessage: error.message + } + )) + }) + } + + this.setState(prevState => ({ + ...prevState, + chipData: data, + ...(data.animalId && { errorMessage: undefined }), // clear error message + ...(data.animalId && { + summaryData: [ + ...prevState.summaryData, + { ...data } + ] + }) + })) + } + handleErrorMessage = error => { + this.setState(prevState => ( + { + ...prevState, + errorMessage: error + } + )) + } + render() { + // allow debug mode to be triggered for running test suite + this.debug = this.props.debug !== undefined ? this.props.debug : constants.debug + + const { isLoading } = this.state + + if (isLoading) { + return ( + + ) + } + + return ( +
+ { this.isSerialSupported + && ( +
+
+
+

Current Animal

+
+
+ +
+ +
+ +
+
+
+

Animals Seen

+
+
+ +
+
+
+
+ ) } + +
+ ) + } +} diff --git a/snprc_ehr/src/client/ChipReader/api/fetchAnimalId.js b/snprc_ehr/src/client/ChipReader/api/fetchAnimalId.js new file mode 100644 index 000000000..55300f368 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/api/fetchAnimalId.js @@ -0,0 +1,30 @@ +import { executeSql } from '../../Shared/api/api' +import moment from 'moment' + +const parse = rows => { + return rows.map(({ data }) => { + return { value: data.Id.value, url: data.Id.url, location: data.location.value, time: moment() } + }) +} + +const fetchAnimalId = chipId => { + const sql = `SELECT ih.Id AS Id, ih.Id.curLocation.location as location + FROM study.idHistory as ih + WHERE ih.value = '${chipId}'` + + return new Promise((resolve, reject) => { + + executeSql({ + schemaName: 'study', + sql + }).then(({ rows }) => { + const parseRows = parse(rows) + if (parseRows.length !== 1) + throw new Error ("Animal not found") + resolve(parseRows[0]) + }).catch(error => { + reject(error) + }) + }) +} +export default fetchAnimalId diff --git a/snprc_ehr/src/client/ChipReader/app.jsx b/snprc_ehr/src/client/ChipReader/app.jsx new file mode 100644 index 000000000..9b0354eb3 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/app.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import ChipReader from './ChipReader' + +// Need to wait for container element to be available in labkey wrapper before render +window.addEventListener('DOMContentLoaded', () => { + ReactDOM.render(, document.getElementById('app')) +}) diff --git a/snprc_ehr/src/client/ChipReader/components/ChipDataPanel.jsx b/snprc_ehr/src/client/ChipReader/components/ChipDataPanel.jsx new file mode 100644 index 000000000..c2faabeac --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/components/ChipDataPanel.jsx @@ -0,0 +1,159 @@ +/* eslint-disable no-await-in-loop */ + +import React from 'react' +import { requestPort, connect, close } from '../services/serialService' +import { getChipData } from '../services/microChipReader' +import constants from '../constants/index' + +export default class ChipDataPanel extends React.Component { + state = { + isReading: false, + connection: undefined + } + componentDidMount = () => { + console.log('ChipDataPanel mounted') + } + onConnectClick = () => { + if (!this.state.connection) { + requestPort().then(serialPort => connect(serialPort, this.props.serialOptions).then(connection => { + this.props.handleErrorMessage(undefined) + this.props.handleSetConnection(connection) + })).catch(error => { + this.props.handleErrorMessage(error.message) + }) + } + } + flushPromises = () => { return new Promise(resolve => setImmediate(resolve)) } + onStartClick = async () => { + if (this.state.connection && !this.state.isReading) { + this.setState(prevState => ( + { + ...prevState, + isReading: true + } + ), async () => { + this.props.handleDataChange(undefined) + // let data + + console.log('starting reader') + + // serial port read loop + while (this.state.isReading) { + const data = await getChipData(this.state.connection) + .then(results => { + // clear error message + if ((this.props.errorMessage && results && results.animalId) + || this.props.errorMessage === constants.timeOutErrorMessage) { + this.props.handleErrorMessage(undefined) + } + return results + }) + + .catch(error => { + this.props.handleErrorMessage(error.message) + }) + + await this.flushPromises() // interupt event loop + + if (data && data.chipId.length > 0) { + this.props.handleDataChange(data) + } + } + }) + } + } + onQuitClick = () => { + // close serial connection + if (this.state.connection) { + this.setState(prevState => ( // shutdown reading before closing connection + { + ...prevState, + isReading: false + } + ), () => { + close(this.state.connection).then(() => { + this.props.handleSetConnection(undefined) + }).catch(error => { + this.props.handleErrorMessage(error.message) + }) + }) + } + } + handleChange = () => { /* lint fix */ } + render() { + this.state.connection = this.props.connection && this.props.connection + + const { chipId, animalId, temperature } = this.props.chipData && this.props.chipData + + return ( + <> +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+ + { // display reader state + (this.state.connection && !this.state.isReading &&
Reader connected - not reading
) + || (this.state.isReading &&
Reader connected - reading
) + ||
Reader disconnected
+ } + +
+ + + +
+
+
+ + + ) + } +} diff --git a/snprc_ehr/src/client/ChipReader/components/SummaryGridItem.jsx b/snprc_ehr/src/client/ChipReader/components/SummaryGridItem.jsx new file mode 100644 index 000000000..d56c1400e --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/components/SummaryGridItem.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import moment from 'moment' + +export default class SummaryGridItem extends React.PureComponent { + onClickHandler = () => { + const { id } = this.props.row + this.props.print(id) + } + onClick = () => { + const fullPath = this.props.row.animalId.url + const left = window.screenX + 20 + + // window.open(fullPath, '_blank', `location=yes,height=1024,width=1500,status=no,scrollbars=yes,menubar=yes,left=${left}`) + window.open(fullPath, '_blank', `height=1024,width=1500,left=${left}`) + } + render() { + const { animalId, chipId, temperature } = this.props.row + return ( + + + { animalId.value } + + + { chipId } + + + { moment(animalId.time).format('MM/DD/YYYY h:mm:ss A') } + + + { temperature } + + + { animalId.location } + + + ) + } +} diff --git a/snprc_ehr/src/client/ChipReader/components/SummaryGridPanel.jsx b/snprc_ehr/src/client/ChipReader/components/SummaryGridPanel.jsx new file mode 100644 index 000000000..bd371aad9 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/components/SummaryGridPanel.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { Table } from 'react-bootstrap' +import SummaryGridItem from './SummaryGridItem' + +export default class SummaryGridPanel extends React.PureComponent { + render() { + return ( + <> +
+ + + + + + + + + + + + { this.props.summaryData.length === 0 && } + { this.props.summaryData.length > 0 && this.props.summaryData.map(row => { + return ( + + ) + }) } + +
Animal IdChip IdDatetemperaturelocation
No animals
+
+ + ) + } +} diff --git a/snprc_ehr/src/client/ChipReader/constants/chipReaderState.js b/snprc_ehr/src/client/ChipReader/constants/chipReaderState.js new file mode 100644 index 000000000..d1b19a664 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/constants/chipReaderState.js @@ -0,0 +1,17 @@ + +const ChipReaderState = () => { + + return ({ + port: undefined, + serialOptions: [], + connection: undefined, + chipData: {chipId: undefined, animalId: undefined, temperature: undefined, time: undefined}, + summaryData: [], + isLoading: true, + hasError: false, + showCancelModal: false, + errorMessage: undefined + }) +} + +export default ChipReaderState diff --git a/snprc_ehr/src/client/ChipReader/constants/index.js b/snprc_ehr/src/client/ChipReader/constants/index.js new file mode 100644 index 000000000..826231c45 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/constants/index.js @@ -0,0 +1,26 @@ +export default { + debug: false, + notSupportedMessage: `Sorry, Serial.API is not supported on this device, make sure you're + running Chrome 78 or later and have enabled the #enable-experimental-web-platform-features flag in + chrome://flags`, + timeOutErrorMessage: 'Chip reader is not responding. Ensure you are connected to the correct port, and the reader is turned on.', + validSerialOptions: [ 'baudrate', 'databits', 'stopbits', 'parity', 'buffersize', 'rtscts', 'xon', 'xoff', 'xany' ], + defaultSerialOptions: { + baudrate: 19200, + databits: 8, + stopbits: 1, + buffersize: 255, + parity: 'none', + rctscts: false, + xon: true, + xoff: true, + xany: false + } +} + +// https://wicg.github.io/serial/#dom-serial +// parity is an enum: "none", "even", "odd", "mark", "space" +// baudrate member: One of 115200, 57600, 38400, 19200, 9600, 4800, 2400, 1800, 1200, 600, 300, 200, 150, 134, 110, 75, or 50. +// databits member: One of 8, 7, 6, or 5. +// stopbits member: One of 1 or 2. +// rctscts, xon, xoff, xany: boolean \ No newline at end of file diff --git a/snprc_ehr/src/client/ChipReader/dev.jsx b/snprc_ehr/src/client/ChipReader/dev.jsx new file mode 100644 index 000000000..4bac5b947 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/dev.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { AppContainer } from 'react-hot-loader' + +import ChipReader from './ChipReader' + +const render = () => { + ReactDOM.render( + + + , + document.getElementById('app') + ) +} + +if (module.hot) { + module.hot.accept() +} + +render() diff --git a/snprc_ehr/src/client/ChipReader/services/__mocks__/microChipReader.js b/snprc_ehr/src/client/ChipReader/services/__mocks__/microChipReader.js new file mode 100644 index 000000000..8936dc160 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/services/__mocks__/microChipReader.js @@ -0,0 +1,29 @@ +export const getChipData = async (connection) => { + + let data + let dataArr = [] + let dataObj + + // read serial port with a 2 second timeout + data = "12345, 27.3" + + + if (data) { + // process chip data + if (data.indexOf('XX') === -1) { // clean queue + + data = data === 'XXXXXXXXXX\r' ? '' : data.replace(/\r/g, '') + dataArr = data.split(",") + const len = dataArr.length + + dataObj = { + chipId: dataArr[0].trim(), + ...(len === 1 && { animalId: undefined, temperature: undefined }), + ...(len === 2 && { animalId: undefined, temperature: dataArr[1].trim() }), + ...(len === 3 && { animalId: dataArr[1].trim(), temperature: dataArr[2].trim() }) + } + } + } + + return dataObj +} diff --git a/snprc_ehr/src/client/ChipReader/services/__mocks__/serialService.js b/snprc_ehr/src/client/ChipReader/services/__mocks__/serialService.js new file mode 100644 index 000000000..4414a29cb --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/services/__mocks__/serialService.js @@ -0,0 +1,68 @@ +import constants from '../../constants/index' + +export const requestPort = async () => { + return ( {port: {readable: {}, writable: {}} }) +} + +export const connect = async (port, serialOptions) => { + + const inputDone = null + const inputStream = null + + const outputDone = null + const outputStream = null + + const connection = { + port: port, + serialOptions: serialOptions, + reader: null, + writer: null, + + inputDone: inputDone, + outputDone: outputDone, + inputStream: inputStream, + outputStream: outputStream + } + return connection +} + + +export const readWithTimeout = function (ms, promise) { + + promise.then(value => { + return value + }) + // let timerId = null; + + // // Create a promise that rejects in the specified timeout period (ms) + // let timeout = new Promise((resolve, reject) => { + // timerId = setTimeout(() => { + // clearTimeout(timerId) + // reject(new Error (constants.timeOutErrorMessage)) + // }, ms) + // }) + + // // Returns a race between our timeout and the passed in promise + // return Promise.race([ + // promise.then(value => { + // clearTimeout(timerId) + // return value + // }), + // timeout + // ]) +} + +export const read = async (connection) => { + if (!connection) + throw new Error('Read requires a valid connection object') + + return '1C45433F, 23.4' + +} + +export const close = async (connection) => { + if(!connection) + throw new Error('Close requires a valid connection object') + connection.port = null +} + \ No newline at end of file diff --git a/snprc_ehr/src/client/ChipReader/services/microChipReader.js b/snprc_ehr/src/client/ChipReader/services/microChipReader.js new file mode 100644 index 000000000..d7ec4c8f9 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/services/microChipReader.js @@ -0,0 +1,34 @@ +import { readWithTimeout, read } from './serialService' + +export const getChipData = async (connection) => { + + let data + let dataArr = [] + let dataObj + + // read serial port with a 2 second timeout + data = await readWithTimeout(2000, read(connection) + .catch(error => { + throw error + })) + + + if (data) { + // process chip data + if (data.indexOf('XX') === -1) { // clean queue + + data = data === 'XXXXXXXXXX\r' ? '' : data.replace(/\r/g, '') + dataArr = data.split(",") + const len = dataArr.length + + dataObj = { + chipId: dataArr[0].trim(), + ...(len === 1 && { animalId: undefined, temperature: undefined }), + ...(len === 2 && { animalId: undefined, temperature: dataArr[1].trim() }), + ...(len === 3 && { animalId: dataArr[1].trim(), temperature: dataArr[2].trim() }) + } + } + } + + return dataObj +} \ No newline at end of file diff --git a/snprc_ehr/src/client/ChipReader/services/serialService.js b/snprc_ehr/src/client/ChipReader/services/serialService.js new file mode 100644 index 000000000..e22c01271 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/services/serialService.js @@ -0,0 +1,127 @@ +import constants from '../constants/index' + +export const requestPort = async () => { + return await navigator.serial.requestPort().catch ( error => { + throw error + }) +} + +export const setSerialOptions = (serialOptions, property, value) => { + if (!serialOptions || !property || !value) + throw new Error('setSerialOptions requires: serialOptions, property, value') + + if (constants.validSerialOptions.indexOf(property) === -1) + throw new Error ('Invalid setSerialOptions Property') + + const newOptions = { + ...serialOptions, + [property]: value + } + + return newOptions + } + +export const connect = async (port, serialOptions) => { + if (!port || !serialOptions) + throw new Error('Connection request requires port and serialOptions') + + + await port.open( serialOptions ).catch( error => { + throw new Error(`Port.open() error: ${error.message}`) + }) + + const decoder = new TextDecoderStream(); + const inputDone = port.readable.pipeTo(decoder.writable) + const inputStream = decoder.readable + + const encoder = new TextEncoderStream() + const outputDone = encoder.readable.pipeTo(port.writable) + const outputStream = encoder.writable + + const connection = { + port: port, + serialOptions: serialOptions, + reader: inputStream.getReader(), + writer: outputStream.getWriter(), + + inputDone: inputDone, + outputDone: outputDone, + inputStream: inputStream, + outputStream: outputStream + } + + return connection +} + +export const write = async (connection, text) => { + if (!connection) + throw new Error('Write requires a valid connection object') + + await connection.writer.write(text) + await connection.writer.write('\r') +} + +export const readWithTimeout = function (ms, promise) { + + let timerId = null; + + // Create a promise that rejects in the specified timeout period (ms) + let timeout = new Promise((resolve, reject) => { + timerId = setTimeout(() => { + clearTimeout(timerId) + reject(new Error (constants.timeOutErrorMessage)) + }, ms) + }) + + // Returns a race between our timeout and the passed in promise + return Promise.race([ + promise.then(value => { + clearTimeout(timerId) + return value + }), + timeout + ]) +} + +export const read = async (connection) => { + if (!connection) + throw new Error('Read requires a valid connection object') + + let response = '' + while (true) { + const { value } = await connection.reader.read().catch( error => { + throw error + }) + if (value) { + response += value + } + + if (value) { + if (value.indexOf('\r') > -1) { + break + } + } + else + break + } + return response +} + +export const close = async (connection) => { + if(!connection) + throw new Error('Close requires a valid connection object') + + if (connection.reader) { + await connection.reader.cancel() + await connection.inputDone.catch(() => {}) + } + + if (connection.outputStream) { + await connection.writer.close() + connection.outputDone = null + } + + await connection.port.close() + connection.port = null +} + \ No newline at end of file diff --git a/snprc_ehr/src/client/ChipReader/styles/chipReader.scss b/snprc_ehr/src/client/ChipReader/styles/chipReader.scss new file mode 100644 index 000000000..2e56b743a --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/styles/chipReader.scss @@ -0,0 +1,32 @@ +@import "../../Shared/styles/style.scss"; +@import "../../Shared/styles/components/infoPanel"; +@import "../../Shared/styles/components/shared"; +@import "../../Shared/styles/components/summaryGridPanel"; +@import "../../Shared/styles/formatting/panels"; +@import "../../Shared/styles/formatting/wizard"; + + +.chip-info-span { + margin: 2rem 1rem 2rem 1rem; + padding: 1rem 0 1rem 1rem; + background-color: $info-green; + border: 1px solid $info-green; + border-radius: 3px; + font-size: $font-size-large; + color: $dark-blue; +} + +.url-span { + color: $button-blue; + text-decoration: underline; + } + + .button-row { + padding: 1rem 1rem 2rem 1rem + } + + .id-table-scroll { + overflow-y: auto; + min-height: 24.5rem; + padding: 0, 0.1rem !important; +} \ No newline at end of file diff --git a/snprc_ehr/src/client/ChipReader/tests/ChipReader.test.jsx b/snprc_ehr/src/client/ChipReader/tests/ChipReader.test.jsx new file mode 100644 index 000000000..ece32c8b2 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/ChipReader.test.jsx @@ -0,0 +1,55 @@ +/* eslint-disable no-unused-vars */ + +import React from 'react' +import { shallow } from 'enzyme' +import Select from 'react-select' +import moment from 'moment' +import ChipReader from '../ChipReader' +import { executeSql } from '../../Shared/api/api' + +jest.mock('../../Shared/api/api') + +beforeAll(() => { + // global.fetch = jest.fn(); +}) + +let wrapper + +beforeEach(() => { + wrapper = shallow() +}) + +afterEach(() => { + wrapper.unmount() +}) + +function flushPromises() { + return new Promise(resolve => setImmediate(resolve)) +} + +describe('ChipReader tests', () => { + test('Should render the landing page with no serial support error.', async () => { + wrapper = shallow() + await flushPromises() + expect(wrapper).toMatchSnapshot() + }) + + test('Should render the landing page without error message.', async () => { + await flushPromises() + expect(wrapper).toMatchSnapshot() + + // spinner should be gone + expect(wrapper.find('LoadingSpinner_LoadingSpinner').exists()).toBeFalsy() + // ChipDataPanel and SummaryGridPanel panels should be present + expect(wrapper.find('ChipDataPanel').exists()).toBeTruthy() + expect(wrapper.find('SummaryGridPanel').exists()).toBeTruthy() + }) + + test('Should render a loading spinner before rendering the landing page.', async () => { + wrapper.unmount() + wrapper = shallow(, { disableLifecycleMethods: true }) + await flushPromises() + expect(wrapper).toMatchSnapshot() + expect(wrapper.find('LoadingSpinner_LoadingSpinner').exists()).toBeTruthy() + }) +}) diff --git a/snprc_ehr/src/client/ChipReader/tests/__snapshots__/ChipReader.test.jsx.snap b/snprc_ehr/src/client/ChipReader/tests/__snapshots__/ChipReader.test.jsx.snap new file mode 100644 index 000000000..7a3d3580b --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/__snapshots__/ChipReader.test.jsx.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChipReader tests Should render a loading spinner before rendering the landing page. 1`] = ` + +`; + +exports[`ChipReader tests Should render the landing page with no serial support error. 1`] = ` +
+ +
+`; + +exports[`ChipReader tests Should render the landing page without error message. 1`] = ` +
+
+
+
+

+ Current Animal +

+
+
+ +
+
+
+
+
+

+ Animals Seen +

+
+
+ +
+
+
+
+ +
+`; diff --git a/snprc_ehr/src/client/ChipReader/tests/components/ChipDataPanel.test.jsx b/snprc_ehr/src/client/ChipReader/tests/components/ChipDataPanel.test.jsx new file mode 100644 index 000000000..fa5b4bb73 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/components/ChipDataPanel.test.jsx @@ -0,0 +1,68 @@ +/* eslint-disable no-unused-vars */ + +import React from 'react' +import { shallow } from 'enzyme' +import moment from 'moment' +import ChipDataPanel from '../../components/ChipDataPanel' +import constants from '../../constants' + +jest.mock('../../services/serialService') + +let wrapper + +beforeEach(() => { + wrapper = shallow( { } } + handleErrorMessage={ () => { } } + handleSetConnection={ () => { } } + serialOptions={ constants.serialOptions } + />) +}) + +afterEach(() => { + wrapper.unmount() +}) + +describe('ChipDataPanel tests', () => { + test('Should render the ChipDataPanel.', () => { + expect(wrapper).toMatchSnapshot() + }) + + test('Should connect to reader.', () => { + const connectButton = wrapper.find('#connect') + connectButton.simulate('click') + wrapper.setProps({ connection: { port: {} } }) + expect(wrapper).toMatchSnapshot() + const connectTextEl = wrapper.find('.chip-info-span') + expect(connectTextEl.text().trim()).toEqual('Reader connected - not reading') + connectTextEl.unmount() + }) + + test('Should start reading.', () => { + wrapper.setProps({ connection: { port: {} } }) // test requires a connection object + const startButton = wrapper.find('#start') + startButton.simulate('click') + expect(wrapper).toMatchSnapshot() + const readTextEl = wrapper.find('.chip-info-span') + expect(readTextEl.text().trim()).toEqual('Reader connected - reading') + wrapper.setState({ isReading: false }) // cancel reading after tests (exit read loop) + }) + + test('Should stop reading.', () => { + // start reading + wrapper.setProps({ connection: { port: {} } }) // test requires a connection object + wrapper.find('#start').simulate('click') + expect(wrapper.find('.chip-info-span').text().trim()).toEqual('Reader connected - reading') + + // quit reading + const stopButton = wrapper.find('#stop') + stopButton.simulate('click') + wrapper.setProps({ connection: undefined, isReading: false }) // close connection object + + expect(wrapper).toMatchSnapshot() + expect(wrapper.find('.chip-info-span').text().trim()).toEqual('Reader disconnected') + }) +}) diff --git a/snprc_ehr/src/client/ChipReader/tests/components/SummaryGridPanel.test.jsx b/snprc_ehr/src/client/ChipReader/tests/components/SummaryGridPanel.test.jsx new file mode 100644 index 000000000..6e4df6690 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/components/SummaryGridPanel.test.jsx @@ -0,0 +1,26 @@ +/* eslint-disable no-unused-vars */ + +import React from 'react' +import { shallow } from 'enzyme' +import moment from 'moment' +import SummaryGridPanel from '../../components/SummaryGridPanel' +import { summaryData } from '../fixtures/apiTestData' + +function flushPromises() { + return new Promise(resolve => setImmediate(resolve)) +} + +describe('SummaryGridPanel tests', () => { + test('Should render empty SummaryGridPanel.', async () => { + const mathRandomSpy = jest.spyOn(Math, 'random') + mathRandomSpy.mockImplementation(() => 0.5) + + const wrapper = shallow() + await flushPromises() + expect(wrapper).toMatchSnapshot() + wrapper.unmount() + }) +}) diff --git a/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/ChipDataPanel.test.jsx.snap b/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/ChipDataPanel.test.jsx.snap new file mode 100644 index 000000000..4e991f412 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/ChipDataPanel.test.jsx.snap @@ -0,0 +1,453 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChipDataPanel tests Should connect to reader. 1`] = ` + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ Reader connected - not reading +
+
+ + + +
+
+
+
+`; + +exports[`ChipDataPanel tests Should render the ChipDataPanel. 1`] = ` + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ Reader disconnected +
+
+ + + +
+
+
+
+`; + +exports[`ChipDataPanel tests Should start reading. 1`] = ` + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ Reader connected - reading +
+
+ + + +
+
+
+
+`; + +exports[`ChipDataPanel tests Should stop reading. 1`] = ` + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ Reader disconnected +
+
+ + + +
+
+
+
+`; diff --git a/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/SummaryGridPanel.test.jsx.snap b/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/SummaryGridPanel.test.jsx.snap new file mode 100644 index 000000000..a8b6b569a --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/components/__snapshots__/SummaryGridPanel.test.jsx.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SummaryGridPanel tests Should render empty SummaryGridPanel. 1`] = ` + +
+ + + + + + + + + + + + + + + +
+ Animal Id + + Chip Id + + Date + + temperature + + location +
+
+
+`; diff --git a/snprc_ehr/src/client/ChipReader/tests/fixtures/apiTestData.js b/snprc_ehr/src/client/ChipReader/tests/fixtures/apiTestData.js new file mode 100644 index 000000000..94bba8544 --- /dev/null +++ b/snprc_ehr/src/client/ChipReader/tests/fixtures/apiTestData.js @@ -0,0 +1,53 @@ +/* eslint-disable camelcase */ + +export const animalIdData = { + schemaName: "study", + queryName: "sql", + rows: [{ + data: { + location: { + value: "2.00-42" + }, + Id: { + "value": "12345", + "url": "/labkey/ehr/SNPRC/participantView.view?participantId=12345" + } + } + }], + rowCount: 1 +} + +export const summaryData = + [ + { + chipId: "1C45433F", + animalId: { + value: "12345", + url: "/labkey/ehr/SNPRC/participantView.view?participantId=12345", + location: "2.00-42", + time: "2020-08-18T16:47:44.704Z" + }, + temperature: "<25.0" + }, + { + chipId: "2C45433F", + animalId: { + value: "11111", + url: "/labkey/ehr/SNPRC/participantView.view?participantId=11111", + location: "2.00-42", + time: "2020-08-18T16:48:53.150Z" + }, + temperature: "26.2" + }, + { + chipId: "3C45433F", + animalId: { + value: "22222", + url: "/labkey/ehr/SNPRC/participantView.view?participantId=22222", + location: "2.00-42", + time: "2020-08-18T16:49:56.729Z" + }, + temperature: "26.3" + } + ] + \ No newline at end of file diff --git a/snprc_ehr/src/client/Shared/styles/settings.scss b/snprc_ehr/src/client/Shared/styles/settings.scss index 830512976..bfbf1c471 100644 --- a/snprc_ehr/src/client/Shared/styles/settings.scss +++ b/snprc_ehr/src/client/Shared/styles/settings.scss @@ -9,6 +9,7 @@ $off-white: #f7f7f7; $info-red: #c24343; $info-blue: #100796; $tooltip-yellow: #e7e693; +$info-green: #7aec76c2; // Font Size $font-size-xlarge: 3.6rem; $font-size-large: 1.8rem; diff --git a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRController.java b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRController.java index 710693754..028ceeb5d 100644 --- a/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRController.java +++ b/snprc_ehr/src/org/labkey/snprc_ehr/SNPRC_EHRController.java @@ -49,6 +49,7 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.security.permissions.UpdatePermission; import org.labkey.api.util.GUID; import org.labkey.api.util.URLHelper; @@ -380,6 +381,15 @@ public URLHelper getRedirectURL(Object o) } } + @RequiresPermission(ReadPermission.class) + public class IdChipReaderAction extends SimpleRedirectAction + { + @Override + public URLHelper getRedirectURL(Object o) + { + return new ActionURL(NAME, "ChipReader", getContainer()); + } + } @RequiresPermission(SNPRCColonyAdminPermission.class) public class NewAnimalWizardAction extends SimpleRedirectAction diff --git a/snprc_ehr/webpack/entryPoints.js b/snprc_ehr/webpack/entryPoints.js index d16e0b6d6..f811d9177 100644 --- a/snprc_ehr/webpack/entryPoints.js +++ b/snprc_ehr/webpack/entryPoints.js @@ -10,12 +10,17 @@ module.exports = { permission: 'read', path: './src/client/NewAnimalPage', scriptType: 'jsx' - }, - { + },{ name: 'BirthRecordReport', title: 'Birth Record Report', permission: 'read', path: './src/client/BirthRecordReport', scriptType: 'jsx' + },{ + name: 'ChipReader', + title: 'Chip Reader', + permission: 'read', + path: './src/client/ChipReader', + scriptType: 'jsx' }] -}; \ No newline at end of file +};