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
+ && (
+
+ ) }
+
+
+ )
+ }
+}
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 (
+ <>
+
+
+
+
+ | Animal Id |
+ Chip Id |
+ Date |
+ temperature |
+ location |
+
+
+
+ { this.props.summaryData.length === 0 && | No animals |
}
+ { this.props.summaryData.length > 0 && this.props.summaryData.map(row => {
+ return (
+
+ )
+ }) }
+
+
+
+ >
+ )
+ }
+}
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`] = `
+
+`;
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
+};