From 995038a4b42fb0cf440763117977e80c4296ab88 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Fri, 14 Sep 2018 13:44:21 -0400 Subject: [PATCH] feat(app): Populate FileInfo page with JSON protocol metadata (#2278) Closes #2129 --- .../components/FileInfo/InformationCard.js | 78 ++++-- app/src/components/FileInfo/LabwareTable.js | 4 - .../FileInfo/ProtocolLabwareCard.js | 19 +- .../FileInfo/ProtocolModulesCard.js | 104 ++++---- .../FileInfo/ProtocolPipettesCard.js | 69 +++--- app/src/components/FileInfo/index.js | 29 +-- app/src/components/FileInfo/styles.css | 16 +- app/src/components/UploadError/index.js | 46 +--- app/src/components/UploadError/styles.css | 56 +---- app/src/components/UploadPanel/Upload.js | 10 +- app/src/components/UploadPanel/index.js | 35 ++- app/src/components/layout/styles.css | 2 - app/src/pages/Upload/FileInfo.js | 23 +- app/src/pages/Upload/index.js | 54 +++-- app/src/protocol/__tests__/selectors.test.js | 227 ++++++++++++++++++ app/src/protocol/index.js | 110 +++++++-- app/src/robot/reducer/session.js | 49 ++-- app/src/robot/selectors.js | 2 +- app/src/robot/test/session-reducer.test.js | 4 +- 19 files changed, 630 insertions(+), 307 deletions(-) diff --git a/app/src/components/FileInfo/InformationCard.js b/app/src/components/FileInfo/InformationCard.js index 3425572a713..2e45f8fcb29 100644 --- a/app/src/components/FileInfo/InformationCard.js +++ b/app/src/components/FileInfo/InformationCard.js @@ -1,32 +1,74 @@ // @flow import * as React from 'react' +import {connect} from 'react-redux' +import moment from 'moment' + +import { + getProtocolName, + getProtocolAuthor, + getProtocolLastUpdated, + getProtocolMethod, + getProtocolDescription, +} from '../../protocol' import {LabeledValue} from '@opentrons/components' import InfoSection from './InfoSection' -import {SectionContentHalf} from '../layout' +import {SectionContentHalf, CardRow} from '../layout' -import styles from './styles.css' +import type {State} from '../../types' type Props = { - name: string, + name: ?string, + author: ?string, + lastUpdated: ?number, + method: ?string, + description: ?string, } -const TITLE = 'Information' -export default function InformationCard (props: Props) { - const {name} = props +const INFO_TITLE = 'Information' +const DESCRIPTION_TITLE = 'Description' +const DATE_FORMAT = 'DD MMM Y, hh:mmA' - let createdBy = 'Opentrons API' - if (name.endsWith('.json')) { - createdBy = 'Protocol Designer' - } +export default connect(mapStateToProps)(InformationCard) + +function InformationCard (props: Props) { + const {name, author, method, description} = props + const lastUpdated = props.lastUpdated + ? moment(props.lastUpdated).format(DATE_FORMAT) + : '-' return ( - - - - - - - - + + + + + + + + + + + + + + + + + + {description && ( + +

{description}

+
+ )} +
) } + +function mapStateToProps (state: State): Props { + return { + name: getProtocolName(state), + author: getProtocolAuthor(state), + lastUpdated: getProtocolLastUpdated(state), + method: getProtocolMethod(state), + description: getProtocolDescription(state), + } +} diff --git a/app/src/components/FileInfo/LabwareTable.js b/app/src/components/FileInfo/LabwareTable.js index c6b97ddf85e..3f8325547b8 100644 --- a/app/src/components/FileInfo/LabwareTable.js +++ b/app/src/components/FileInfo/LabwareTable.js @@ -3,14 +3,11 @@ import * as React from 'react' import styles from './styles.css' type Props = { - title: string, children: React.Node, } export default function LabwareTable (props: Props) { return ( -
-

{props.title}

@@ -20,6 +17,5 @@ export default function LabwareTable (props: Props) { {props.children}
-
) } diff --git a/app/src/components/FileInfo/ProtocolLabwareCard.js b/app/src/components/FileInfo/ProtocolLabwareCard.js index 021e9deb31e..f076d266429 100644 --- a/app/src/components/FileInfo/ProtocolLabwareCard.js +++ b/app/src/components/FileInfo/ProtocolLabwareCard.js @@ -3,23 +3,28 @@ import * as React from 'react' import {connect} from 'react-redux' import countBy from 'lodash/countBy' -import type {State} from '../../types' -import type {Labware} from '../../robot' + import {selectors as robotSelectors} from '../../robot' +import InfoSection from './InfoSection' import LabwareTable from './LabwareTable' +import type {State} from '../../types' +import type {Labware} from '../../robot' + type Props = { labware: Array, } const TITLE = 'Required Labware' -export default connect(mapStateToProps, null)(ProtocolLabwareCard) +export default connect(mapStateToProps)(ProtocolLabwareCard) function ProtocolLabwareCard (props: Props) { const {labware} = props - const labwareCount = countBy(labware, 'name') + if (labware.length === 0) return null + + const labwareCount = countBy(labware, 'name') const labwareList = Object.keys(labwareCount).map(type => ( {type} @@ -28,9 +33,9 @@ function ProtocolLabwareCard (props: Props) { )) return ( - - {labwareList} - + + {labwareList} + ) } function mapStateToProps (state: State): Props { diff --git a/app/src/components/FileInfo/ProtocolModulesCard.js b/app/src/components/FileInfo/ProtocolModulesCard.js index 59127946409..ecc45a695ce 100644 --- a/app/src/components/FileInfo/ProtocolModulesCard.js +++ b/app/src/components/FileInfo/ProtocolModulesCard.js @@ -3,11 +3,12 @@ import * as React from 'react' import {connect} from 'react-redux' -import type {State} from '../../types' -import type {Robot, SessionModule} from '../../robot' - import {selectors as robotSelectors} from '../../robot' -import {makeGetRobotModules, fetchModules, type FetchModulesResponse} from '../../http-api-client' +import { + makeGetRobotModules, + fetchModules, + type FetchModulesResponse, +} from '../../http-api-client' import {RefreshWrapper} from '../Page' import InfoSection from './InfoSection' @@ -15,37 +16,44 @@ import {SectionContentHalf} from '../layout' import InstrumentItem from './InstrumentItem' import InstrumentWarning from './InstrumentWarning' +import type {State} from '../../types' +import type {Robot, SessionModule} from '../../robot' + +type OP = { + robot: Robot, +} + type SP = { modules: Array, actualModules: ?FetchModulesResponse, - _robot: ?Robot, + attachModulesUrl: string, } -type DP = {dispatch: Dispatch} - -type Props = SP & { - attachModulesUrl: string, +type DP = { fetchModules: () => mixed, } +type Props = OP & SP & DP + const TITLE = 'Required Modules' -export default connect(makeMapStateToProps, null, mergeProps)(ProtocolModulesCard) +export default connect( + makeMapStateToProps, + mapDispatchToProps +)(ProtocolModulesCard) function ProtocolModulesCard (props: Props) { - const { - modules, - actualModules, - fetchModules, - attachModulesUrl, - } = props - - const moduleInfo = modules.map((module) => { - let displayName = module.name === 'tempdeck' - ? 'Temperature Module' - : 'Magnetic Bead Module' - - const actualModel = actualModules && actualModules.modules.find((m) => m.name === module.name) + const {modules, actualModules, fetchModules, attachModulesUrl} = props + + if (modules.length < 1) return null + + const moduleInfo = modules.map(module => { + const displayName = + module.name === 'tempdeck' ? 'Temperature Module' : 'Magnetic Bead Module' + + const actualModel = + actualModules && actualModules.modules.find(m => m.name === module.name) + const modulesMatch = actualModel != null && actualModel.name === module.name return { @@ -55,51 +63,43 @@ function ProtocolModulesCard (props: Props) { } }) - const modulesMatch = moduleInfo.every((m) => m.modulesMatch) - - if (modules.length < 1) return null + const modulesMatch = moduleInfo.every(m => m.modulesMatch) return ( - - - - {moduleInfo.map((m) => ( - {m.displayName} - ))} - - {!modulesMatch && ( - - )} - + + + + {moduleInfo.map(m => ( + + {m.displayName}{' '} + + ))} + + {!modulesMatch && ( + + )} + ) } -function makeMapStateToProps (): (state: State) => SP { +function makeMapStateToProps (): (state: State, ownProps: OP) => SP { const getActualModules = makeGetRobotModules() - return (state, props) => { - const _robot = robotSelectors.getConnectedRobot(state) - const modulesCall = _robot && getActualModules(state, _robot) + return (state, ownProps) => { + const {robot} = ownProps + const modulesCall = getActualModules(state, robot) return { - _robot, modules: robotSelectors.getModules(state), actualModules: modulesCall && modulesCall.response, + attachModulesUrl: `/robots/${robot.name}/instruments`, } } } -function mergeProps (stateProps: SP, dispatchProps: DP): Props { - const {dispatch} = dispatchProps - const {_robot} = stateProps - const attachModulesUrl = _robot ? `/robots/${_robot.name}/instruments` : '/robots' - +function mapDispatchToProps (dispatch: Dispatch, ownProps: OP): DP { return { - ...stateProps, - attachModulesUrl, - fetchModules: () => _robot && dispatch(fetchModules(_robot)), + fetchModules: () => dispatch(fetchModules(ownProps.robot)), } } diff --git a/app/src/components/FileInfo/ProtocolPipettesCard.js b/app/src/components/FileInfo/ProtocolPipettesCard.js index 92fc8b77daa..941489e373d 100644 --- a/app/src/components/FileInfo/ProtocolPipettesCard.js +++ b/app/src/components/FileInfo/ProtocolPipettesCard.js @@ -16,36 +16,37 @@ import {SectionContentHalf} from '../layout' import InfoSection from './InfoSection' import InstrumentWarning from './InstrumentWarning' +type OP = { + robot: Robot, +} + type SP = { pipettes: Array, actualPipettes: ?PipettesResponse, - _robot: ?Robot, } -type DP = {dispatch: Dispatch} - -type Props = SP & { +type DP = { fetchPipettes: () => mixed, changePipetteUrl: string, } +type Props = OP & SP & DP + const TITLE = 'Required Pipettes' -export default connect(makeMapStateToProps, null, mergeProps)(ProtocolPipettesCard) +export default connect( + makeMapStateToProps, + mapDispatchToProps +)(ProtocolPipettes) -function ProtocolPipettesCard (props: Props) { - const { - pipettes, - actualPipettes, - fetchPipettes, - changePipetteUrl, - } = props +function ProtocolPipettes (props: Props) { + const {pipettes, actualPipettes, fetchPipettes, changePipetteUrl} = props + + if (pipettes.length === 0) return null - const pipetteInfo = pipettes.map((p) => { + const pipetteInfo = pipettes.map(p => { const pipetteConfig = getPipette(p.name) - const displayName = !pipetteConfig - ? 'N/A' - : pipetteConfig.displayName + const displayName = !pipetteConfig ? 'N/A' : pipetteConfig.displayName const actualModel = actualPipettes && actualPipettes[p.mount].model let pipettesMatch = true @@ -61,49 +62,49 @@ function ProtocolPipettesCard (props: Props) { } }) - const pipettesMatch = pipetteInfo.every((p) => p.pipettesMatch) + const pipettesMatch = pipetteInfo.every(p => p.pipettesMatch) return ( - + - {pipetteInfo.map((p) => ( - {p.displayName} + {pipetteInfo.map(p => ( + + {p.displayName} + ))} {!pipettesMatch && ( - + )} ) } -function makeMapStateToProps (): (state: State) => SP { +function makeMapStateToProps (): (state: State, ownProps: OP) => SP { const getAttachedPipettes = makeGetRobotPipettes() - return (state, props) => { - const _robot = robotSelectors.getConnectedRobot(state) - const pipettesCall = _robot && getAttachedPipettes(state, _robot) + return (state, ownProps) => { + const pipettesCall = getAttachedPipettes(state, ownProps.robot) return { - _robot, pipettes: robotSelectors.getPipettes(state), actualPipettes: pipettesCall && pipettesCall.response, } } } -function mergeProps (stateProps: SP, dispatchProps: DP): Props { - const {dispatch} = dispatchProps - const {_robot} = stateProps - const changePipetteUrl = _robot ? `/robots/${_robot.name}/instruments` : '/robots' +function mapDispatchToProps (dispatch: Dispatch, ownProps: OP): DP { + const {robot} = ownProps + const changePipetteUrl = `/robots/${robot.name}/instruments` return { - ...stateProps, changePipetteUrl, - fetchPipettes: () => _robot && dispatch(fetchPipettes(_robot)), + fetchPipettes: () => dispatch(fetchPipettes(robot)), } } diff --git a/app/src/components/FileInfo/index.js b/app/src/components/FileInfo/index.js index c40148abd04..f669a3df0e9 100644 --- a/app/src/components/FileInfo/index.js +++ b/app/src/components/FileInfo/index.js @@ -3,38 +3,33 @@ import * as React from 'react' import type {Robot} from '../../robot' +// TODO(mc, 2018-09-13): these aren't cards; rename import InformationCard from './InformationCard' import ProtocolPipettesCard from './ProtocolPipettesCard' import ProtocolModulesCard from './ProtocolModulesCard' import ProtocolLabwareCard from './ProtocolLabwareCard' import Continue from './Continue' -import {CardRow} from '../layout' +import UploadError from '../UploadError' import styles from './styles.css' type Props = { - name: string, robot: Robot, + sessionLoaded: ?boolean, + uploadError: ?{message: string}, } export default function FileInfo (props: Props) { + const {robot, sessionLoaded, uploadError} = props + return (
- - - - - - - - - - - - - - - + + + + + {uploadError && } + {sessionLoaded && }
) } diff --git a/app/src/components/FileInfo/styles.css b/app/src/components/FileInfo/styles.css index e55768709bc..5222932cb62 100644 --- a/app/src/components/FileInfo/styles.css +++ b/app/src/components/FileInfo/styles.css @@ -6,13 +6,23 @@ } .info_section { - border-bottom: 1px solid var(--c-light-gray); + width: 100%; + padding-top: 1.25rem; + padding-bottom: 1.25rem; + + & > p { + @apply --font-body-2-dark; + } + + &:not(:last-of-type) { + border-bottom: 1px solid var(--c-light-gray); + } } .title { @apply --font-header-dark; - margin: 1rem 0; + margin: 0 0 1rem; font-weight: var(--fw-regular); text-transform: capitalize; } @@ -85,5 +95,5 @@ .continue_button { display: inline-block; width: 16.5rem; - margin-top: 1rem; + margin-bottom: 1rem; } diff --git a/app/src/components/UploadError/index.js b/app/src/components/UploadError/index.js index 5f347d7afe6..7cf6f8b42e5 100644 --- a/app/src/components/UploadError/index.js +++ b/app/src/components/UploadError/index.js @@ -1,7 +1,6 @@ // @flow // upload summary component import * as React from 'react' -import cx from 'classnames' import {Icon} from '@opentrons/components' import styles from './styles.css' @@ -9,48 +8,27 @@ import styles from './styles.css' const UPLOAD_ERROR_MESSAGE = 'Your protocol could not be opened.' type Props = { - name: string, - uploadError: ?{message: string}, + uploadError: {message: string}, } -export default function UploadStatus (props: Props) { - const {name, uploadError} = props +export default function UploadError (props: Props) { + const {uploadError} = props return ( -
-
- +
+
-

- {UPLOAD_ERROR_MESSAGE} -

+

{UPLOAD_ERROR_MESSAGE}

-

- File details: -

+

{uploadError.message}

- Filename: {name} -

-
-
-

- {uploadError && (uploadError.message)} -

-

- Looks like there might be a problem with your protocol file. Please - check your file for errors. If you need help, contact support - and provide them with your protocol file and the error message above. + It looks like there might be a problem with your protocol file. + Please check your file for errors. If you need help, contact support + and provide them with your protocol file and the error message + above.

-
-
- ) -} - -function StatusIcon () { - const iconClassName = cx(styles.status_icon, styles.error) - return ( - + ) } diff --git a/app/src/components/UploadError/styles.css b/app/src/components/UploadError/styles.css index fffb69bbf6b..b426a218e4f 100644 --- a/app/src/components/UploadError/styles.css +++ b/app/src/components/UploadError/styles.css @@ -1,67 +1,31 @@ -/* TODO(mc, 2018-02-09): fix lint errors and remove this ignore */ -/* stylelint-disable no-duplicate-selectors */ - /* Upload component styles */ @import '@opentrons/components'; -.container { +.results { @apply --center-children; width: 100%; - height: 100%; -} - -.results { - display: flex; - align-items: center; + margin-top: 1.25rem; } -.status_icon { +.error_icon { width: 6rem; } .status { + @apply --font-body-2-dark; + margin-left: 2rem; max-width: 24rem; - - & > *:not(:last-child) { - margin-bottom: 1rem; - } -} - -.status_message { - @apply --font-header-dark; - - margin-bottom: 1rem; } -.success { - color: var(--c-green); -} - -.error, +.status_message, .error_message { - color: var(--c-red); -} - -.details { - @apply --font-body-2-dark; + font-weight: var(--fw-semibold); + margin-bottom: 0.75rem; } +.error_icon, .error_message { - font-weight: bold; - margin-bottom: 0.5rem; -} - -.file_details_title { - @apply --font-header-dark; - - margin-top: 0; - margin-bottom: 0.5rem; - font-weight: var(--fw-regular); -} - -.open_setup_link { - cursor: pointer; - color: var(--c-blue); + color: var(--c-red); } diff --git a/app/src/components/UploadPanel/Upload.js b/app/src/components/UploadPanel/Upload.js index a351c7535d0..742d55adb63 100644 --- a/app/src/components/UploadPanel/Upload.js +++ b/app/src/components/UploadPanel/Upload.js @@ -5,8 +5,8 @@ import ConfirmUploadModal from './ConfirmUploadModal' import UploadMenu from './UploadMenu' type Props = { + filename: ?string, sessionLoaded: ?boolean, - uploadError: ? {message: string}, createSession: (file: File) => mixed, } @@ -41,7 +41,7 @@ export default class Upload extends React.Component { } confirmUpload = () => { - const { uploadedFile } = this.state + const {uploadedFile} = this.state if (uploadedFile) { this.props.createSession(uploadedFile) @@ -55,12 +55,14 @@ export default class Upload extends React.Component { render () { const {uploadedFile} = this.state - const {sessionLoaded, uploadError} = this.props + const {filename} = this.props + return ( - {sessionLoaded && !uploadError && ()} + {filename && } + {uploadedFile && ( mixed, - createSession: () => mixed, + uploadError: ?{message: string}, +} + +type DP = { + createSession: File => mixed, } -export default connect(mapStateToProps, mapDispatchToProps)(UploadPanel) +type Props = SP & DP + +export default connect( + mapStateToProps, + mapDispatchToProps +)(UploadPanel) function UploadPanel (props: Props) { return ( - + ) } -function mapStateToProps (state) { +function mapStateToProps (state: State): SP { return { + filename: getProtocolFilename(state), sessionLoaded: robotSelectors.getSessionIsLoaded(state), uploadError: robotSelectors.getUploadError(state), } } -function mapDispatchToProps (dispatch) { +function mapDispatchToProps (dispatch: Dispatch): DP { return { - confirmUpload: () => dispatch(push('/upload/confirm')), - createSession: (file) => dispatch(openProtocol(file)), + createSession: (file: File) => dispatch(openProtocol(file)), } } diff --git a/app/src/components/layout/styles.css b/app/src/components/layout/styles.css index 4e7b43ac5c3..90e4705e545 100644 --- a/app/src/components/layout/styles.css +++ b/app/src/components/layout/styles.css @@ -66,12 +66,10 @@ .section_content_full { width: 100%; overflow-y: auto; - padding-bottom: 1rem; } .section_content_50 { display: inline-block; vertical-align: top; width: 50%; - padding-bottom: 1rem; } diff --git a/app/src/pages/Upload/FileInfo.js b/app/src/pages/Upload/FileInfo.js index 6afac5458eb..ca3d5bbb2e6 100644 --- a/app/src/pages/Upload/FileInfo.js +++ b/app/src/pages/Upload/FileInfo.js @@ -1,23 +1,36 @@ // @flow import * as React from 'react' -import type {Robot} from '../../robot' + +import {SpinnerModal} from '@opentrons/components' import Page from '../../components/Page' import FileInfo from '../../components/FileInfo' +import type {Robot} from '../../robot' + type Props = { - name: string, robot: Robot, + filename: string, + uploadInProgress: boolean, + uploadError: ?{message: string}, + sessionLoaded: boolean, } export default function FileInfoPage (props: Props) { + const {robot, filename, uploadInProgress, uploadError, sessionLoaded} = props + return ( - + + {uploadInProgress && } ) } diff --git a/app/src/pages/Upload/index.js b/app/src/pages/Upload/index.js index 2b72828b55d..eac2b784807 100644 --- a/app/src/pages/Upload/index.js +++ b/app/src/pages/Upload/index.js @@ -9,53 +9,55 @@ import type {Robot} from '../../robot' import {selectors as robotSelectors} from '../../robot' import {getProtocolFilename} from '../../protocol' -import {Splash, SpinnerModal} from '@opentrons/components' +import {Splash} from '@opentrons/components' import Page from '../../components/Page' -import UploadError from '../../components/UploadError' import FileInfo from './FileInfo' type SP = { robot: ?Robot, - name: ?string, + filename: ?string, uploadInProgress: boolean, uploadError: ?{message: string}, - protocolRunning: boolean, - protocolDone: boolean, + sessionLoaded: boolean, } type OP = {match: Match} type Props = SP & OP -export default withRouter( - connect(mapStateToProps)(UploadPage) -) +export default withRouter(connect(mapStateToProps)(UploadPage)) function mapStateToProps (state: State, ownProps: OP): SP { const connectedRobot = robotSelectors.getConnectedRobot(state) + return { robot: connectedRobot, - name: getProtocolFilename(state), + filename: getProtocolFilename(state), uploadInProgress: robotSelectors.getSessionLoadInProgress(state), uploadError: robotSelectors.getUploadError(state), - protocolRunning: robotSelectors.getIsRunning(state), - protocolDone: robotSelectors.getIsDone(state), + sessionLoaded: robotSelectors.getSessionIsLoaded(state), } } function UploadPage (props: Props) { - const {robot, name, uploadInProgress, uploadError, match: {path}} = props - const fileInfoPath = `${path}/file-info` - - if (!robot) return () - if (!name) return () + const { + robot, + filename, + uploadInProgress, + uploadError, + sessionLoaded, + match: {path}, + } = props - if (uploadInProgress) { - return () - } + const fileInfoPath = `${path}/file-info` - if (uploadError) { - return () + if (!robot) return + if (!filename) { + return ( + + + + ) } return ( @@ -63,7 +65,15 @@ function UploadPage (props: Props) { ()} + render={props => ( + + )} /> ) diff --git a/app/src/protocol/__tests__/selectors.test.js b/app/src/protocol/__tests__/selectors.test.js index 0b7704588ce..bf2404bd4af 100644 --- a/app/src/protocol/__tests__/selectors.test.js +++ b/app/src/protocol/__tests__/selectors.test.js @@ -9,6 +9,24 @@ const SPECS = [ state: {protocol: {file: {name: 'proto.json'}}}, expected: {name: 'proto.json'}, }, + { + name: 'getProtocolContents', + selector: protocol.getProtocolContents, + state: {protocol: {file: {name: 'proto.json'}, contents: 'fizzbuzz'}}, + expected: 'fizzbuzz', + }, + { + name: 'getProtocolData', + selector: protocol.getProtocolData, + state: { + protocol: { + file: {name: 'proto.json'}, + contents: 'fizzbuzz', + data: {metadata: {}}, + }, + }, + expected: {metadata: {}}, + }, { name: 'getProtocolFilename with no file', selector: protocol.getProtocolFilename, @@ -21,6 +39,215 @@ const SPECS = [ state: {protocol: {file: {name: 'proto.json'}}}, expected: 'proto.json', }, + { + name: 'getProtocolName with nothing loaded', + selector: protocol.getProtocolName, + state: {protocol: {file: null, contents: null, data: null}}, + expected: null, + }, + { + name: 'getProtocolName from filename if no data', + selector: protocol.getProtocolName, + state: { + protocol: {file: {name: 'proto.json'}, contents: null, data: null}, + }, + expected: 'proto', + }, + { + name: 'getProtocolName from filename if data does not include name', + selector: protocol.getProtocolName, + state: { + protocol: { + file: {name: 'proto.json'}, + contents: 'fizzbuzz', + data: {metadata: {}}, + }, + }, + expected: 'proto', + }, + { + name: 'getProtocolName from metadata', + selector: protocol.getProtocolName, + state: { + protocol: { + file: {name: 'proto.json'}, + contents: 'fizzbuzz', + data: {metadata: {'protocol-name': 'A Protocol'}}, + }, + }, + expected: 'A Protocol', + }, + { + name: 'getProtocolAuthor if no data', + selector: protocol.getProtocolAuthor, + state: {protocol: {data: null}}, + expected: undefined, + }, + { + name: 'getProtocolAuthor if author not in metadata', + selector: protocol.getProtocolAuthor, + state: {protocol: {data: {metadata: {}}}}, + expected: undefined, + }, + { + name: 'getProtocolAuthor if author in metadata', + selector: protocol.getProtocolAuthor, + state: {protocol: {data: {metadata: {author: 'Fizz Buzz'}}}}, + expected: 'Fizz Buzz', + }, + { + name: 'getProtocolDescription if no data', + selector: protocol.getProtocolDescription, + state: {protocol: {data: null}}, + expected: undefined, + }, + { + name: 'getProtocolDescription if description not in metadata', + selector: protocol.getProtocolDescription, + state: {protocol: {data: {metadata: {}}}}, + expected: undefined, + }, + { + name: 'getProtocolDescription if description in metadata', + selector: protocol.getProtocolDescription, + state: {protocol: {data: {metadata: {description: 'Fizzes buzzes'}}}}, + expected: 'Fizzes buzzes', + }, + { + name: 'getProtocolLastUpdated falls back to file if no data', + selector: protocol.getProtocolDescription, + state: {protocol: {file: {lastModifieddata: null}}}, + expected: undefined, + }, + { + name: 'getProtocolDescription if description not in metadata', + selector: protocol.getProtocolDescription, + state: {protocol: {data: {metadata: {}}}}, + expected: undefined, + }, + { + name: 'getProtocolDescription if description in metadata', + selector: protocol.getProtocolDescription, + state: {protocol: {data: {metadata: {description: 'Fizzes buzzes'}}}}, + expected: 'Fizzes buzzes', + }, + { + name: 'getProtocolLastUpdated if nothing loaded', + selector: protocol.getProtocolLastUpdated, + state: {protocol: {file: null, contents: null, data: null}}, + expected: null, + }, + { + name: 'getProtocolLastUpdated from file.lastModified', + selector: protocol.getProtocolLastUpdated, + state: {protocol: {file: {lastModified: 1}}}, + expected: 1, + }, + { + name: 'getProtocolLastUpdated from metadata.created', + selector: protocol.getProtocolLastUpdated, + state: { + protocol: { + file: {lastModified: 1}, + data: {metadata: {created: 2}}, + }, + }, + expected: 2, + }, + { + name: 'getProtocolLastUpdated from metadata.last-modified', + selector: protocol.getProtocolLastUpdated, + state: { + protocol: { + file: {lastModified: 1}, + data: {metadata: {created: 2, 'last-modified': 3}}, + }, + }, + expected: 3, + }, + { + name: 'getProtocolMethod if nothing loaded', + selector: protocol.getProtocolMethod, + state: {protocol: {file: null, contents: null, data: null}}, + expected: null, + }, + { + name: 'getProtocolMethod if file not yet read', + selector: protocol.getProtocolMethod, + state: {protocol: {file: {name: 'proto.py'}, contents: null, data: null}}, + expected: null, + }, + { + name: 'getProtocolMethod if non-JSON file has been read', + selector: protocol.getProtocolMethod, + state: { + protocol: { + file: {name: 'proto.py'}, + contents: 'fizzbuzz', + data: null, + }, + }, + expected: 'Opentrons API', + }, + { + name: 'getProtocolMethod if JSON file read but no data', + selector: protocol.getProtocolMethod, + state: { + protocol: { + file: {name: 'proto.json', type: 'application/json'}, + contents: 'fizzbuzz', + data: null, + }, + }, + expected: 'Unknown Application', + }, + { + name: 'getProtocolMethod if JSON file read but no name in data', + selector: protocol.getProtocolMethod, + state: { + protocol: { + file: {name: 'proto.py', type: 'application/json'}, + contents: 'fizzbuzz', + data: {metadata: {}}, + }, + }, + expected: 'Unknown Application', + }, + { + name: 'getProtocolMethod if JSON file read with name in data', + selector: protocol.getProtocolMethod, + state: { + protocol: { + file: {name: 'proto.py', type: 'application/json'}, + contents: 'fizzbuzz', + data: { + metadata: {}, + 'designer-application': { + 'application-name': 'opentrons/protocol-designer', + }, + }, + }, + }, + expected: 'Opentrons Protocol Designer', + }, + { + name: 'getProtocolMethod if JSON file read with name and version in data', + selector: protocol.getProtocolMethod, + state: { + protocol: { + file: {name: 'proto.py', type: 'application/json'}, + contents: 'fizzbuzz', + data: { + metadata: {}, + 'designer-application': { + 'application-name': 'opentrons/protocol-designer', + 'application-version': 'v1.2.3', + }, + }, + }, + }, + expected: 'Opentrons Protocol Designer v1.2.3', + }, ] describe('protocol selectors', () => { diff --git a/app/src/protocol/index.js b/app/src/protocol/index.js index 618fd76878a..906bc7b4e5a 100644 --- a/app/src/protocol/index.js +++ b/app/src/protocol/index.js @@ -1,28 +1,31 @@ // @flow // protocol state and loading actions -import { createSelector } from 'reselect' - +import path from 'path' +import startCase from 'lodash/startCase' +import {createSelector} from 'reselect' +import {getter} from '@thi.ng/paths' import { fileToProtocolFile, parseProtocolData, + fileIsJson, filenameToType, } from './protocol-data' -import type { Selector } from 'reselect' -import type { State, Action, ThunkAction } from '../types' -import type { ProtocolState, ProtocolFile, ProtocolData } from './types' +import type {Selector} from 'reselect' +import type {State, Action, ThunkAction} from '../types' +import type {ProtocolState, ProtocolFile, ProtocolData} from './types' export * from './types' type OpenProtocolAction = {| type: 'protocol:OPEN', - payload: {| file: ProtocolFile |}, + payload: {|file: ProtocolFile|}, |} type UploadProtocolAction = {| type: 'protocol:UPLOAD', - payload: {| contents: string, data: ?ProtocolData |}, - meta: {| robot: true |}, + payload: {|contents: string, data: ?ProtocolData|}, + meta: {|robot: true|}, |} export type ProtocolAction = OpenProtocolAction | UploadProtocolAction @@ -33,7 +36,7 @@ export function openProtocol (file: File): ThunkAction { const protocolFile = fileToProtocolFile(file) const openAction: OpenProtocolAction = { type: 'protocol:OPEN', - payload: { file: protocolFile }, + payload: {file: protocolFile}, } reader.onload = () => { @@ -41,8 +44,8 @@ export function openProtocol (file: File): ThunkAction { const contents: string = (reader.result: any) const uploadAction: UploadProtocolAction = { type: 'protocol:UPLOAD', - payload: { contents, data: parseProtocolData(protocolFile, contents) }, - meta: { robot: true }, + payload: {contents, data: parseProtocolData(protocolFile, contents)}, + meta: {robot: true}, } dispatch(uploadAction) @@ -53,7 +56,7 @@ export function openProtocol (file: File): ThunkAction { } } -const INITIAL_STATE = { file: null, contents: null, data: null } +const INITIAL_STATE = {file: null, contents: null, data: null} export function protocolReducer ( state: ProtocolState = INITIAL_STATE, @@ -61,19 +64,21 @@ export function protocolReducer ( ): ProtocolState { switch (action.type) { case 'protocol:OPEN': - return { ...INITIAL_STATE, ...action.payload } + return {...INITIAL_STATE, ...action.payload} case 'protocol:UPLOAD': - return { ...state, ...action.payload } + return {...state, ...action.payload} case 'robot:SESSION_RESPONSE': { const {name, protocolText: contents} = action.payload - const file = !state.file || name !== state.file.name - ? {name, type: filenameToType(name), lastModified: null} - : state.file - const data = !state.contents || contents !== state.contents - ? parseProtocolData(file, contents) - : state.data + const file = + !state.file || name !== state.file.name + ? {name, type: filenameToType(name), lastModified: null} + : state.file + const data = + !state.contents || contents !== state.contents + ? parseProtocolData(file, contents) + : state.data return {file, contents, data} } @@ -85,11 +90,76 @@ export function protocolReducer ( return state } +type StringGetter = (?ProtocolData) => ?string +type NumberGetter = (?ProtocolData) => ?number type StringSelector = Selector +type NumberSelector = Selector + +const getName: StringGetter = getter('metadata.protocol-name') +const getAuthor: StringGetter = getter('metadata.author') +const getDesc: StringGetter = getter('metadata.description') +const getCreated: NumberGetter = getter('metadata.created') +const getLastModified: NumberGetter = getter('metadata.last-modified') +const getAppName: StringGetter = getter('designer-application.application-name') +const getAppVersion: StringGetter = getter( + 'designer-application.application-version' +) +const stripDirAndExtension = f => path.basename(f, path.extname(f)) export const getProtocolFile = (state: State) => state.protocol.file +export const getProtocolContents = (state: State) => state.protocol.contents +export const getProtocolData = (state: State) => state.protocol.data export const getProtocolFilename: StringSelector = createSelector( getProtocolFile, file => file && file.name ) + +export const getProtocolLastModified: NumberSelector = createSelector( + getProtocolFile, + file => file && file.lastModified +) + +export const getProtocolName: StringSelector = createSelector( + getProtocolFilename, + getProtocolData, + (name, data) => getName(data) || (name && stripDirAndExtension(name)) +) + +export const getProtocolAuthor: StringSelector = createSelector( + getProtocolData, + data => getAuthor(data) +) + +export const getProtocolDescription: StringSelector = createSelector( + getProtocolData, + data => getDesc(data) +) + +export const getProtocolLastUpdated: NumberSelector = createSelector( + getProtocolFile, + getProtocolData, + (file, data) => + getLastModified(data) || getCreated(data) || (file && file.lastModified) +) + +const METHOD_OT_API = 'Opentrons API' +const METHOD_UNKNOWN = 'Unknown Application' + +export const getProtocolMethod: StringSelector = createSelector( + getProtocolFile, + getProtocolContents, + getProtocolData, + (file, contents, data) => { + const isJson = file && fileIsJson(file) + const appName = getAppName(data) + const appVersion = getAppVersion(data) + const readableName = appName && startCase(appName) + + if (!file || !contents) return null + if (isJson === true && !readableName) return METHOD_UNKNOWN + if (!isJson) return METHOD_OT_API + if (readableName && appVersion) return `${readableName} ${appVersion}` + return readableName + } +) diff --git a/app/src/robot/reducer/session.js b/app/src/robot/reducer/session.js index 7b75102dcd1..dbd2c49ad8c 100644 --- a/app/src/robot/reducer/session.js +++ b/app/src/robot/reducer/session.js @@ -14,7 +14,7 @@ import type { SessionStatus, } from '../types' -import type {DisconnectResponseAction, SessionUpdateAction} from '../actions' +import type {SessionUpdateAction} from '../actions' type Request = { inProgress: boolean, @@ -88,12 +88,14 @@ export default function sessionReducer ( ): State { switch (action.type) { case 'robot:DISCONNECT_RESPONSE': - return handleDisconnectResponse(state, action) + return INITIAL_STATE case 'robot:SESSION_UPDATE': return handleSessionUpdate(state, action) case 'protocol:UPLOAD': + return handleSessionInProgress(INITIAL_STATE) + case 'robot:REFRESH_SESSION': return handleSessionInProgress(state) @@ -101,32 +103,33 @@ export default function sessionReducer ( case 'robot:SESSION_ERROR': return handleSessionResponse(state, action) - case RUN: return handleRun(state, action) - case RUN_RESPONSE: return handleRunResponse(state, action) - case PAUSE: return handlePause(state, action) - case PAUSE_RESPONSE: return handlePauseResponse(state, action) - case RESUME: return handleResume(state, action) - case RESUME_RESPONSE: return handleResumeResponse(state, action) - case CANCEL: return handleCancel(state, action) - case CANCEL_RESPONSE: return handleCancelResponse(state, action) - case TICK_RUN_TIME: return handleTickRunTime(state, action) + case RUN: + return handleRun(state, action) + case RUN_RESPONSE: + return handleRunResponse(state, action) + case PAUSE: + return handlePause(state, action) + case PAUSE_RESPONSE: + return handlePauseResponse(state, action) + case RESUME: + return handleResume(state, action) + case RESUME_RESPONSE: + return handleResumeResponse(state, action) + case CANCEL: + return handleCancel(state, action) + case CANCEL_RESPONSE: + return handleCancelResponse(state, action) + case TICK_RUN_TIME: + return handleTickRunTime(state, action) } return state } -function handleDisconnectResponse ( - state: State, - action: DisconnectResponseAction -): State { - return INITIAL_STATE -} - -function handleSessionUpdate ( - state: State, - action: SessionUpdateAction -): State { - const {payload: {state: sessionState, startTime, lastCommand}} = action +function handleSessionUpdate (state: State, action: SessionUpdateAction): State { + const { + payload: {state: sessionState, startTime, lastCommand}, + } = action let {protocolCommandsById} = state if (lastCommand) { diff --git a/app/src/robot/selectors.js b/app/src/robot/selectors.js index 1369742360c..50d36554dcd 100644 --- a/app/src/robot/selectors.js +++ b/app/src/robot/selectors.js @@ -109,7 +109,7 @@ export const getConnectionStatus: Selector = } ) -export function getSessionLoadInProgress (state: State) { +export function getSessionLoadInProgress (state: State): boolean { return sessionRequest(state).inProgress } diff --git a/app/src/robot/test/session-reducer.test.js b/app/src/robot/test/session-reducer.test.js index 4c263697ee2..ed344487ef8 100644 --- a/app/src/robot/test/session-reducer.test.js +++ b/app/src/robot/test/session-reducer.test.js @@ -44,6 +44,7 @@ describe('robot reducer - session', () => { }) test('handles protocol:UPLOAD action', () => { + const initialState = reducer(undefined, {}).session const state = { session: { sessionRequest: {inProgress: false, error: new Error('AH')}, @@ -57,9 +58,8 @@ describe('robot reducer - session', () => { } expect(reducer(state, action).session).toEqual({ + ...initialState, sessionRequest: {inProgress: true, error: null}, - startTime: null, - runTime: 0, }) })