From 5281aff261eea4ee67f36566f45c1bfba32435a8 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 31 Jul 2020 15:53:25 +0100 Subject: [PATCH 01/71] remove existing setup screen entirely --- src/components/Setup/CharInput.js | 58 ---- src/components/Setup/DiscoveredServerList.js | 144 --------- src/components/Setup/NewSessionOverlay.js | 84 ----- src/components/Setup/PairedServerWrapper.js | 147 --------- src/components/Setup/PairingCodeInput.js | 89 ------ src/components/Setup/ProtocolCard.js | 160 ---------- src/components/Setup/ProtocolCardMini.js | 31 -- src/components/Setup/ServerAddressForm.js | 142 --------- src/components/Setup/ServerCard.js | 92 ------ src/components/Setup/ServerPairingForm.js | 106 ------- src/components/Setup/ServerProtocolList.js | 40 --- src/components/Setup/ServerSetup.js | 42 --- src/components/Setup/ServerUnavailable.js | 23 -- .../Setup/__tests__/CharInput.test.js | 29 -- .../__tests__/DiscoveredServerList.test.js | 44 --- .../Setup/__tests__/PairingCodeInput.test.js | 38 --- .../Setup/__tests__/ProtocolCard.test.js | 42 --- .../Setup/__tests__/ServerAddressForm.test.js | 36 --- .../Setup/__tests__/ServerPairingForm.test.js | 39 --- .../__tests__/ServerProtocolList.test.js | 23 -- src/components/Setup/index.js | 12 - src/containers/Setup/ExportProgressOverlay.js | 106 ------- src/containers/Setup/ExportSessionsOverlay.js | 287 ------------------ src/containers/Setup/ImportProgressOverlay.js | 79 ----- src/containers/Setup/ProtocolImportOverlay.js | 192 ------------ src/containers/Setup/ProtocolList.js | 143 --------- src/containers/Setup/ProtocolUrlForm.js | 59 ---- src/containers/Setup/ServerPairing.js | 139 --------- src/containers/Setup/ServerProtocols.js | 94 ------ src/containers/Setup/SessionListContainer.js | 210 ------------- src/containers/Setup/SetupScreen.js | 122 -------- src/containers/Setup/index.js | 8 - src/routes.js | 3 +- src/styles/components/_all.scss | 10 - src/styles/components/_pairing-form.scss | 19 -- .../components/_protocol-card-mini.scss | 36 --- src/styles/components/_protocol-card.scss | 130 -------- src/styles/components/_protocol.scss | 11 - .../components/_server-address-form.scss | 13 - src/styles/components/_server-card.scss | 47 --- .../components/_server-protocol-list.scss | 17 -- src/styles/components/_server-setup.scss | 21 -- .../components/_server-unavailable.scss | 4 - src/styles/components/_session-list.scss | 174 ----------- src/styles/containers/_all.scss | 5 - .../containers/_protocol-import-overlay.scss | 66 ---- src/styles/containers/_protocol-list.scss | 19 -- src/styles/containers/_server-list.scss | 32 -- .../containers/_session-export-overlay.scss | 137 --------- src/styles/containers/_setupScreen.scss | 254 ---------------- 50 files changed, 2 insertions(+), 3856 deletions(-) delete mode 100644 src/components/Setup/CharInput.js delete mode 100644 src/components/Setup/DiscoveredServerList.js delete mode 100644 src/components/Setup/NewSessionOverlay.js delete mode 100644 src/components/Setup/PairedServerWrapper.js delete mode 100644 src/components/Setup/PairingCodeInput.js delete mode 100644 src/components/Setup/ProtocolCard.js delete mode 100644 src/components/Setup/ProtocolCardMini.js delete mode 100644 src/components/Setup/ServerAddressForm.js delete mode 100644 src/components/Setup/ServerCard.js delete mode 100644 src/components/Setup/ServerPairingForm.js delete mode 100644 src/components/Setup/ServerProtocolList.js delete mode 100644 src/components/Setup/ServerSetup.js delete mode 100644 src/components/Setup/ServerUnavailable.js delete mode 100644 src/components/Setup/__tests__/CharInput.test.js delete mode 100644 src/components/Setup/__tests__/DiscoveredServerList.test.js delete mode 100644 src/components/Setup/__tests__/PairingCodeInput.test.js delete mode 100644 src/components/Setup/__tests__/ProtocolCard.test.js delete mode 100644 src/components/Setup/__tests__/ServerAddressForm.test.js delete mode 100644 src/components/Setup/__tests__/ServerPairingForm.test.js delete mode 100644 src/components/Setup/__tests__/ServerProtocolList.test.js delete mode 100644 src/components/Setup/index.js delete mode 100644 src/containers/Setup/ExportProgressOverlay.js delete mode 100644 src/containers/Setup/ExportSessionsOverlay.js delete mode 100644 src/containers/Setup/ImportProgressOverlay.js delete mode 100644 src/containers/Setup/ProtocolImportOverlay.js delete mode 100644 src/containers/Setup/ProtocolList.js delete mode 100644 src/containers/Setup/ProtocolUrlForm.js delete mode 100644 src/containers/Setup/ServerPairing.js delete mode 100644 src/containers/Setup/ServerProtocols.js delete mode 100644 src/containers/Setup/SessionListContainer.js delete mode 100644 src/containers/Setup/SetupScreen.js delete mode 100644 src/containers/Setup/index.js delete mode 100644 src/styles/components/_pairing-form.scss delete mode 100644 src/styles/components/_protocol-card-mini.scss delete mode 100644 src/styles/components/_protocol-card.scss delete mode 100644 src/styles/components/_protocol.scss delete mode 100644 src/styles/components/_server-address-form.scss delete mode 100644 src/styles/components/_server-card.scss delete mode 100644 src/styles/components/_server-protocol-list.scss delete mode 100644 src/styles/components/_server-setup.scss delete mode 100644 src/styles/components/_server-unavailable.scss delete mode 100644 src/styles/components/_session-list.scss delete mode 100644 src/styles/containers/_protocol-import-overlay.scss delete mode 100644 src/styles/containers/_protocol-list.scss delete mode 100644 src/styles/containers/_server-list.scss delete mode 100644 src/styles/containers/_session-export-overlay.scss delete mode 100644 src/styles/containers/_setupScreen.scss diff --git a/src/components/Setup/CharInput.js b/src/components/Setup/CharInput.js deleted file mode 100644 index 73466df6f2..0000000000 --- a/src/components/Setup/CharInput.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -// TODO: Share with Server -// eslint-disable-next-line @codaco/spellcheck/spell-checker -const charSet = 'abcdefghijklmnopqrstuvwxyz'.split(''); - -const CharInput = ({ value, disabled, index, onChange }) => { - let className = 'pairing-code-input__character'; - if (value && !charSet.includes(value)) { - className += ' pairing-code-input__character--error'; - } - return ( - - evt.target.select()} - type="text" - value={value} - /> - - ); -}; - -CharInput.focusNext = (currentInput) => { - const nextLabel = currentInput.nextElementSibling; - if (nextLabel && nextLabel.focus) { - nextLabel.focus(); - } -}; - -CharInput.focusPrevious = (currentInput) => { - const prevLabel = currentInput.previousElementSibling; - const prevInput = prevLabel && prevLabel.previousElementSibling; - if (prevInput && prevInput.focus) { - prevInput.focus(); - } -}; - -CharInput.defaultProps = { - disabled: false, -}; - -CharInput.propTypes = { - value: PropTypes.string.isRequired, - disabled: PropTypes.bool, - index: PropTypes.number.isRequired, - onChange: PropTypes.func.isRequired, -}; - -export default CharInput; diff --git a/src/components/Setup/DiscoveredServerList.js b/src/components/Setup/DiscoveredServerList.js deleted file mode 100644 index 8741fc6b41..0000000000 --- a/src/components/Setup/DiscoveredServerList.js +++ /dev/null @@ -1,144 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { Icon, Spinner } from '@codaco/ui'; -import ServerDiscoverer from '../../utils/serverDiscoverer'; -import ServerCard from '../../components/Setup/ServerCard'; -import Scroller from '../Scroller'; - -const loadingPlaceholder = ( -
-

Listening for nearby Servers...

- -
-); - -/** - * Displays a list of available servers discovered via MDNS. - */ -class DiscoveredServerList extends Component { - constructor() { - super(); - - this.state = { - error: null, - servers: [], - }; - } - componentDidMount() { - this.initServer(); - } - - componentDidUpdate() { - // Give some console output about the error, just for debugging. - if (this.state.error) { - // eslint-disable-next-line - console.warn(this.state.error); - } - } - - componentWillUnmount() { - this.serverDiscoverer.removeAllListeners(); - } - - bindServerEvents = () => { - if (!this.serverDiscoverer) { return; } - - this.serverDiscoverer.on('SERVER_RESET', () => { - this.setState({ - error: null, - }); - }); - - this.serverDiscoverer.on('SERVER_ANNOUNCED', (response) => { - if (!response.name) { return; } - - const servers = this.state.servers.slice(); - // Detect if we already have a service with this name - const serverIndex = this.state.servers.findIndex(server => response.name === server.name); - if (serverIndex === -1) { - servers.push(response); - } else { - servers[serverIndex] = response; - } - this.setState(() => ({ servers })); - }); - - this.serverDiscoverer.on('SERVER_REMOVED', (response) => { - this.setState(prevState => ({ - servers: prevState.servers.filter(item => (item.name !== response.name)), - })); - }); - - this.serverDiscoverer.on('SERVER_ERROR', (error) => { - this.setState({ error }); - }); - } - - initServer = () => { - try { - this.serverDiscoverer = new ServerDiscoverer(); - this.bindServerEvents(); - this.serverDiscoverer.init(); - } catch (error) { - this.setState({ error }); - } - } - - renderServerList() { - const { selectServer } = this.props; - return ( - -

Nearby devices running Server:

- - { - this.state.servers.map(server => ( - - )) - } - -
- ); - } - - renderError() { - return ( -
- -

Automatic server discovery unavailable

- { - }

{this.state.error}

-
- ); - } - - render() { - const serversAvailable = !this.state.error && this.state.servers.length > 0; - const className = classNames('server-list', { 'server-list--available': serversAvailable }); - - if (this.state.error) { - return (
{this.renderError()}
); - } - - if (serversAvailable) { - return (
{this.renderServerList()}
); - } - - return (
{loadingPlaceholder}
); - } -} - -DiscoveredServerList.defaultProps = { - selectPairedServer: () => {}, - selectServer: () => {}, -}; - -DiscoveredServerList.propTypes = { - selectServer: PropTypes.func, -}; - -export default DiscoveredServerList; diff --git a/src/components/Setup/NewSessionOverlay.js b/src/components/Setup/NewSessionOverlay.js deleted file mode 100644 index 91323503d0..0000000000 --- a/src/components/Setup/NewSessionOverlay.js +++ /dev/null @@ -1,84 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@codaco/ui'; -import Overlay from '../../containers/Overlay'; -import { Form } from '../../containers'; - - -class NewSessionOverlay extends Component { - constructor(props) { - super(props); - - this.state = { - caseId: '', - }; - - this.overlay = React.createRef(); - } - - onSubmitForm = (fields) => { - this.props.handleSubmit(fields.case_id); - } - - render() { - const { show, onClose } = this.props; - - const formConfig = { - formName: 'case-id-form', - fields: [ - { - label: null, - name: 'case_id', - component: 'Text', - placeholder: 'Enter a unique case ID', - validation: { - required: true, - maxLength: 30, - }, - }, - ], - }; - - return ( - -

- Before the interview begins, enter a case ID. - This will be shown on the resume interview screen to help you quickly - identify this session. -

-
-
- -
-
-
- ); - } -} - -NewSessionOverlay.propTypes = { - show: PropTypes.bool, - handleSubmit: PropTypes.func.isRequired, - onClose: PropTypes.func, -}; - -NewSessionOverlay.defaultProps = { - show: false, - onClose: () => {}, -}; - -export default NewSessionOverlay; diff --git a/src/components/Setup/PairedServerWrapper.js b/src/components/Setup/PairedServerWrapper.js deleted file mode 100644 index 47bf74e438..0000000000 --- a/src/components/Setup/PairedServerWrapper.js +++ /dev/null @@ -1,147 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { Button } from '@codaco/ui'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { actionCreators as serverActions } from '../../ducks/modules/pairedServer'; -import ApiClient from '../../utils/ApiClient'; -import { ServerUnavailable } from '../../components/Setup'; -import logo from '../../images/Srv-Flat.svg'; - -const noClick = () => {}; - -/** - * Renders a server icon & label. The label defaults to server name, falling back - * to its first address (both provided via the `data` prop). If `secondaryLabel` - * is provided, then it will be appended. - */ -class PairedServerWrapper extends Component { - constructor(props) { - super(props); - this.state = { - error: null, - }; - } - - componentDidMount() { - const { pairedServer } = this.props; - this.apiClient = new ApiClient(pairedServer); - this.testServerConnection(); - } - - handleApiError(err) { - this.setState({ error: err }); - } - - handleRetry = () => { - this.setState({ error: null }, this.testServerConnection); - } - - testServerConnection = () => { - // TODO: replace this with a dedicated heartbeat endpoint on Server - this.apiClient - .addTrustedCert() - .then(() => this.apiClient.getProtocols()) - .catch(err => this.handleApiError(err)); - } - - render() { - const { - data, - secondaryLabel, - selectServer, - className, - openDialog, - unpairServer, - children, - } = this.props; - - const { - error, - } = this.state; - - const cssClass = classNames( - 'server-card', - { 'server-card--error': error }, - { 'server-card--clickable': selectServer !== noClick }, - className, - ); - const { name, addresses = [] } = data; - let label = name || addresses[0]; - if (secondaryLabel) { - label += ` ${secondaryLabel}`; - } - - const handleUnpairRequest = () => { - openDialog({ - type: 'Warning', - title: 'Unpair this Server?', - confirmLabel: 'Unpair Server', - onConfirm: unpairServer, - message: 'This will remove the connection to this instance of Server. Are you sure you want to continue?', - }); - }; - - return ( -
-
selectServer(data)} > - Available Server -

- {label} -

- -
-
- { error ? - ( - - ) : children - } -
-
- ); - } -} - -PairedServerWrapper.propTypes = { - data: PropTypes.shape({ - name: PropTypes.string, - addresses: PropTypes.array, - }), - className: PropTypes.string, - selectServer: PropTypes.func, - secondaryLabel: PropTypes.string, - unpairServer: PropTypes.func.isRequired, - openDialog: PropTypes.func.isRequired, - children: PropTypes.node, - pairedServer: PropTypes.object, -}; - -PairedServerWrapper.defaultProps = { - data: {}, - className: '', - selectServer: noClick, - secondaryLabel: null, - children: null, - pairedServer: null, -}; - -function mapDispatchToProps(dispatch) { - return { - openDialog: bindActionCreators(dialogActions.openDialog, dispatch), - unpairServer: bindActionCreators(serverActions.unpairServer, dispatch), - }; -} - -function mapStateToProps(state) { - return { - pairedServer: state.pairedServer, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(PairedServerWrapper); diff --git a/src/components/Setup/PairingCodeInput.js b/src/components/Setup/PairingCodeInput.js deleted file mode 100644 index 3e98aae69d..0000000000 --- a/src/components/Setup/PairingCodeInput.js +++ /dev/null @@ -1,89 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; - -import CharInput from './CharInput'; - -const onKeyUp = (evt) => { - if (evt.key === 'Backspace') { - const input = evt.currentTarget.querySelector('input:focus'); - CharInput.focusPrevious(input); - } -}; - -class PairingCodeInput extends Component { - constructor(props) { - super(props); - - this.emptyChars = new Array(props.charCount); - this.emptyChars.fill(''); - Object.freeze(this.emptyChars); - - this.state = { characters: this.emptyChars }; - } - - onChange = (evt) => { - const target = evt.currentTarget; - const index = parseInt(target.getAttribute('data-index'), 10); - let value = target.value; - let newCharacters; - - if (value.length === this.props.charCount) { - // Handle paste of entire code - newCharacters = value.split(''); - target.blur(); - } else { - // TODO: this takes the last form input, which may not be the last typed. - value = value[value.length - 1] || ''; - newCharacters = this.state.characters.slice(); - newCharacters.splice(index, 1, value); - - if (value) { - CharInput.focusNext(target); - } - } - this.updateCharacters(newCharacters); - } - - clearForm() { - this.updateCharacters(this.emptyChars); - } - - updateCharacters(newCharacters) { - this.setState({ characters: newCharacters }, () => ( - this.props.setPairingCode(newCharacters.join('')) - )); - } - - render() { - const { disabled } = this.props; - return ( -
-
- { - this.state.characters.map((char, i) => ( - - )) - } -
-
- ); - } -} - -PairingCodeInput.defaultProps = { - disabled: false, -}; - -PairingCodeInput.propTypes = { - charCount: PropTypes.number.isRequired, - disabled: PropTypes.bool, - setPairingCode: PropTypes.func.isRequired, -}; - -export default PairingCodeInput; diff --git a/src/components/Setup/ProtocolCard.js b/src/components/Setup/ProtocolCard.js deleted file mode 100644 index 19c99d424f..0000000000 --- a/src/components/Setup/ProtocolCard.js +++ /dev/null @@ -1,160 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import cx from 'classnames'; -import { Icon } from '@codaco/ui'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { actionCreators as installedProtocolsActions } from '../../ducks/modules/installedProtocols'; -import { APP_SUPPORTED_SCHEMA_VERSIONS, APP_SCHEMA_VERSION } from '../../config'; - -class ProtocolCard extends React.Component { - isOutdatedProtocol = () => - this.props.protocol.schemaVersion !== APP_SCHEMA_VERSION && - APP_SUPPORTED_SCHEMA_VERSIONS.includes(this.props.protocol.schemaVersion); - - isObsoleteProtocol = () => false; // To be implemented in future, as needed. - - handleDeleteProtocol = () => { - this.props.deleteProtocol(this.props.protocol.uuid); - }; - - handleSchemaOutdatedInfo = () => { - this.props.openDialog({ - type: 'Notice', - title: 'Schema can be updated', - canCancel: false, - message: ( - -

- This protocol uses an older version of the protocol file format, or "schema". -

-

- Newer schema versions support additional features in Network Canvas. During the beta - phase, we kindly request that you update your protocols to the latest version, and - evaluate the newest features as we implement them. To do this, open the original - protocol file it in the latest version of Architect, and follow the migration - instructions. Once migrated, install the new version of the protocol on this device. -

-

- For documentation on this issue, please see our documentation site. -

-

- In the meantime, you can continue to use this protocol to start interviews or - export data. -

-
- ), - }); - }; - - handleSchemaObsoleteInfo = () => { - this.props.openDialog({ - type: 'Error', - title: 'Obsolete Protocol Schema', - canCancel: false, - message: ( - -

- This protocol uses an obsolete version of the protocol file format, or - "schema". -

-

- The version of the schema used by this protocol is incompatible with this version of - Network Canvas. You may still export interview data that has already been collected, - but you may not start additional interviews. -

-

- If you require the ability to start interviews, you can either (1) install an updated - version of this protocol that uses the latest schema, or (2) downgrade your version - of Network Canvas to a version that supports this protocol schema version. -

-

- For documentation on this issue, please see our documentation site. -

-
- ), - }); - }; - - modifierClasses = cx( - 'protocol-card', - { 'protocol-card--info': !this.isObsoleteProtocol() && this.isOutdatedProtocol() }, - { 'protocol-card--error': this.isObsoleteProtocol() }, - ); - - startButtonClasses = this.isObsoleteProtocol() ? ('start-button start-button--disabled') : ('start-button'); - - renderCardIcon() { - if (this.isOutdatedProtocol()) { - return ( -
- -
- ); - } - - if (this.isObsoleteProtocol()) { - return ( -
- -
- ); - } - - return (''); - } - - render() { - const { protocol, selectProtocol } = this.props; - - return ( -
-
- -
- {this.renderCardIcon()} -

{protocol.name}

-

- { protocol.description ? - protocol.description : (No protocol description.)} -

-
selectProtocol(protocol)} - > - -
Start new interview
- -
-
- ); - } -} - -ProtocolCard.defaultProps = { - className: '', - selectProtocol: () => {}, - description: null, -}; - -ProtocolCard.propTypes = { - selectProtocol: PropTypes.func, - openDialog: PropTypes.func.isRequired, - deleteProtocol: PropTypes.func.isRequired, - protocol: PropTypes.shape({ - uuid: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - description: PropTypes.string, - schemaVersion: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - }).isRequired, -}; - -const mapDispatchToProps = { - openDialog: dialogActions.openDialog, - deleteProtocol: installedProtocolsActions.deleteProtocol, -}; - -export { ProtocolCard as UnconnectedProtocolCard }; - -export default connect(null, mapDispatchToProps)(ProtocolCard); diff --git a/src/components/Setup/ProtocolCardMini.js b/src/components/Setup/ProtocolCardMini.js deleted file mode 100644 index 295bbfdce5..0000000000 --- a/src/components/Setup/ProtocolCardMini.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Icon } from '@codaco/ui'; - -const ProtocolCardMini = ({ protocol, selectProtocol }) => ( -
selectProtocol(protocol)}> -
- -
-
-

{protocol.name}

- { protocol.description ? (

{protocol.description}

) : ''} -
-
-); - -ProtocolCardMini.defaultProps = { - className: '', - selectProtocol: () => {}, - description: null, -}; - -ProtocolCardMini.propTypes = { - selectProtocol: PropTypes.func, - protocol: PropTypes.shape({ - name: PropTypes.string.isRequired, - description: PropTypes.string, - }).isRequired, -}; - -export default ProtocolCardMini; diff --git a/src/components/Setup/ServerAddressForm.js b/src/components/Setup/ServerAddressForm.js deleted file mode 100644 index 36f997965b..0000000000 --- a/src/components/Setup/ServerAddressForm.js +++ /dev/null @@ -1,142 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@codaco/ui'; -import { addPairingUrlToService, isValidAddress, isValidPort, maxPort, minPort } from '../../utils/serverAddressing'; - -/** - * @class Renders a form for user to manually enter Server connection info. - */ -class ServerAddressForm extends PureComponent { - constructor(props) { - super(props); - this.state = { - error: {}, - address: props.address || '', - port: props.port || '51001', - }; - } - - onSubmit = (evt) => { - evt.preventDefault(); - const server = addPairingUrlToService({ - addresses: [this.state.address], - port: this.state.port, - }); - if (server.pairingServiceUrl) { - this.props.selectServer(server); - this.props.onCancel(); - } else { - this.setState({ - error: { - address: !isValidAddress(this.state.address), - port: !isValidPort(this.state.port), - }, - }); - } - } - - setAddress(address) { - this.clearError('address'); - this.setState({ address }); - } - - setPort(port) { - this.clearError('port'); - this.setState({ port }); - } - - validateAddress(address) { - const addressError = { address: address && !isValidAddress(address) }; - this.setState({ error: { ...this.state.error, ...addressError } }); - } - - validatePort(port) { - // Currently requires port number. - // If port is not required, then be careful of input containing '+', '-', and 'e', - // which are valid characters in a number field (and will be displayed), but may return - // (in target.value) as '' if they don't form a number. - const portError = { port: !isValidPort(port) }; - this.setState({ error: { ...this.state.error, ...portError } }); - } - - clearError(name) { - const clearedError = { [name]: null }; - this.setState({ error: { ...this.state.error, ...clearedError } }); - } - - render() { - const { onCancel } = this.props; - const { error } = this.state; - const inputClass = 'server-address-form__input'; - const inputErrorClass = `${inputClass} ${inputClass}--error`; - - return ( -
-

Manual connection information

-

- Enter the address and port number of the computer running Server. - These can be found on your Server’s overview screen. -

-
- -
-

Server Address

- this.setAddress(evt.target.value)} - onBlur={evt => this.validateAddress(evt.target.value)} - /> -
-
-

Port

- this.setPort(evt.target.value)} - onBlur={evt => this.validatePort(evt.target.value)} - /> -
-
-
-
- - -
-
-
- ); - } -} - -ServerAddressForm.defaultProps = { - address: '', - onCancel: null, - port: '', -}; - -ServerAddressForm.propTypes = { - address: PropTypes.string, - onCancel: PropTypes.func.isRequired, - port: PropTypes.string, - selectServer: PropTypes.func.isRequired, -}; - -export default ServerAddressForm; diff --git a/src/components/Setup/ServerCard.js b/src/components/Setup/ServerCard.js deleted file mode 100644 index 7f31037890..0000000000 --- a/src/components/Setup/ServerCard.js +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { Button } from '@codaco/ui'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { actionCreators as serverActions } from '../../ducks/modules/pairedServer'; -import logo from '../../images/Srv-Flat.svg'; - -const noClick = () => {}; - -/** - * Renders a server icon & label. The label defaults to server name, falling back - * to its first address (both provided via the `data` prop). If `secondaryLabel` - * is provided, then it will be appended. - */ -const ServerCard = ({ - data, - secondaryLabel, - selectServer, - isPaired, - className, - openDialog, - unpairServer, -}) => { - const cssClass = classNames( - 'server-card', - { 'server-card--paired': isPaired }, - { 'server-card--clickable': selectServer !== noClick }, - className, - ); - const { name, addresses = [] } = data; - let label = name || addresses[0]; - if (secondaryLabel) { - label += ` ${secondaryLabel}`; - } - - const handleUnpairRequest = () => { - openDialog({ - type: 'Warning', - title: 'Unpair this Server?', - confirmLabel: 'Unpair Server', - onConfirm: unpairServer, - message: 'This will remove the connection to this instance of Server. Are you sure you want to continue?', - }); - }; - - return ( -
selectServer(data)} > - Available Server -

- {label} -

- {isPaired && - - } -
- ); -}; - -ServerCard.propTypes = { - data: PropTypes.shape({ - name: PropTypes.string, - addresses: PropTypes.array, - }), - className: PropTypes.string, - isPaired: PropTypes.bool, - selectServer: PropTypes.func, - secondaryLabel: PropTypes.string, - unpairServer: PropTypes.func.isRequired, - openDialog: PropTypes.func.isRequired, -}; - -ServerCard.defaultProps = { - data: {}, - className: '', - isPaired: false, - selectServer: noClick, - secondaryLabel: null, -}; - -function mapDispatchToProps(dispatch) { - return { - openDialog: bindActionCreators(dialogActions.openDialog, dispatch), - unpairServer: bindActionCreators(serverActions.unpairServer, dispatch), - }; -} - -export { ServerCard as UnconnectedServerCard }; - -export default connect(null, mapDispatchToProps)(ServerCard); diff --git a/src/components/Setup/ServerPairingForm.js b/src/components/Setup/ServerPairingForm.js deleted file mode 100644 index d1e71827d1..0000000000 --- a/src/components/Setup/ServerPairingForm.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { PairingCodeLength } from 'secure-comms-api/pairingCodeConfig'; -import { Button, Spinner } from '@codaco/ui'; -import PairingCodeInput from './PairingCodeInput'; - -/** - * @class Renders a form for user to enter the out-of-band pairing code presented by Server. - */ -class ServerPairingForm extends Component { - constructor(props) { - super(props); - this.state = { pairingCode: '' }; - - this.inputRef = React.createRef(); - } - - setPairingCode = (pairingCode) => { - this.setState({ - pairingCode, - submittable: pairingCode.length === PairingCodeLength, - }); - } - - render() { - const { className, completePairing, loading, onCancel, ...props } = this.props; - const { submittable } = this.state; - return ( -
{ - evt.preventDefault(); - if (submittable) { - document.activeElement.blur(); // attempt to hide soft keyboard on tablet - this.setState({ submittable: false }); - completePairing(this.state.pairingCode); - } - }} - {...props} - > -

- You must pair this device with this Server before you can securely exchange data. - This is a one-off process that allows your devices to identify each other. -

- - { - loading && - - - Please acknowledge the pairing request within the Server app to continue. - -
- -
-
- } - - { !loading && - - - Please type the code shown on the Server setup screen into the box below - -
- - - -
-
- } -
- ); - } -} - -ServerPairingForm.defaultProps = { - className: '', - loading: false, -}; - -ServerPairingForm.propTypes = { - className: PropTypes.string, - completePairing: PropTypes.func.isRequired, - loading: PropTypes.bool, - onCancel: PropTypes.func.isRequired, -}; - -export default ServerPairingForm; diff --git a/src/components/Setup/ServerProtocolList.js b/src/components/Setup/ServerProtocolList.js deleted file mode 100644 index 52c0bc2375..0000000000 --- a/src/components/Setup/ServerProtocolList.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Scroller } from '../../components'; -import ProtocolCardMini from './ProtocolCardMini'; - -const EmptyProtocolList = ( -
-

No protocols available

-
-); - -const ServerProtocolList = ({ protocols, selectProtocol }) => { - if (!protocols.length) { - return EmptyProtocolList; - } - - return ( -
- - { - protocols.map(protocol => ( - selectProtocol(p)} - protocol={protocol} - /> - )) - } - -
- ); -}; - -ServerProtocolList.propTypes = { - selectProtocol: PropTypes.func.isRequired, - protocols: PropTypes.array.isRequired, -}; - -export default ServerProtocolList; diff --git a/src/components/Setup/ServerSetup.js b/src/components/Setup/ServerSetup.js deleted file mode 100644 index eb8375ff29..0000000000 --- a/src/components/Setup/ServerSetup.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -/** - * Provides a wrapper for common server setup tasks, rendering a Server card. - * Used for pairing & protocol selection. - */ -const ServerSetup = ({ children, server }) => ( -
-
-
- { - server.secureServiceUrl && ( - -

Select a protocol to import

-

Select a protocol from this Server to import onto this device.

-
- ) - } -
-
- - { children } - -
-); - -ServerSetup.defaultProps = { - children: [], -}; - -ServerSetup.propTypes = { - children: PropTypes.oneOfType([PropTypes.element, PropTypes.arrayOf(PropTypes.element)]), - server: PropTypes.shape({ - addresses: PropTypes.array.isRequired, - pairingServiceUrl: PropTypes.string.isRequired, - secureServiceUrl: PropTypes.string, - host: PropTypes.string, - }).isRequired, -}; - -export default ServerSetup; diff --git a/src/components/Setup/ServerUnavailable.js b/src/components/Setup/ServerUnavailable.js deleted file mode 100644 index c4546f0aff..0000000000 --- a/src/components/Setup/ServerUnavailable.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@codaco/ui'; - -const ServerUnavailable = ({ errorMessage, handleRetry }) => ( -
-

Server unavailable

-

{errorMessage}

- -
-); - -ServerUnavailable.propTypes = { - errorMessage: PropTypes.string.isRequired, - handleRetry: PropTypes.func.isRequired, -}; - -export default ServerUnavailable; diff --git a/src/components/Setup/__tests__/CharInput.test.js b/src/components/Setup/__tests__/CharInput.test.js deleted file mode 100644 index 8188918f38..0000000000 --- a/src/components/Setup/__tests__/CharInput.test.js +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import { shallow } from 'enzyme'; - -import CharInput from '../CharInput'; - -describe('', () => { - let component; - let changeHandler; - - beforeEach(() => { - changeHandler = jest.fn(); - component = shallow(); - }); - - it('renders an input', () => { - expect(component.find('input')).toHaveLength(1); - }); - - it('bubbles change event from input', () => { - component.find('input').simulate('change'); - expect(changeHandler).toHaveBeenCalled(); - }); - - it('highlights invalid characters', () => { - component = shallow(); - expect(component.find('input').prop('className')).toMatch('error'); - }); -}); diff --git a/src/components/Setup/__tests__/DiscoveredServerList.test.js b/src/components/Setup/__tests__/DiscoveredServerList.test.js deleted file mode 100644 index d1332be237..0000000000 --- a/src/components/Setup/__tests__/DiscoveredServerList.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - -import React from 'react'; -import { createStore } from 'redux'; -import { Provider } from 'react-redux'; -import { mount } from 'enzyme'; - -import DiscoveredServerList from '../DiscoveredServerList'; - -jest.mock('../../../utils/serverDiscoverer', () => jest.fn().mockImplementation(() => ({ - init: jest.fn(), - on: jest.fn(), -}))); - -jest.mock('../../../utils/Environment', () => ({ - isCordova: () => false, - isElectron: () => true, -})); - -const mockStore = () => - createStore( - () => ( - { - installedProtocols: { - config: { - registry: {}, - }, - }, - } - ), - ); - -describe('', () => { - let component; - beforeEach(() => { - component = mount(); - }); - - it('displays listening state', () => { - expect(component.find('ServerCard')).toHaveLength(0); - expect(component.text()).toContain('Listening'); - }); -}); diff --git a/src/components/Setup/__tests__/PairingCodeInput.test.js b/src/components/Setup/__tests__/PairingCodeInput.test.js deleted file mode 100644 index 0150b0a1fc..0000000000 --- a/src/components/Setup/__tests__/PairingCodeInput.test.js +++ /dev/null @@ -1,38 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import { shallow } from 'enzyme'; -import { PairingCodeLength } from 'secure-comms-api/pairingCodeConfig'; - -import PairingCodeInput from '../PairingCodeInput'; - -describe('', () => { - const charCount = PairingCodeLength; - let component; - let changeHandler; - - beforeEach(() => { - changeHandler = jest.fn(); - component = shallow(); - }); - - it('renders an input for each char', () => { - expect(component.find('CharInput')).toHaveLength(charCount); - }); - - describe('change event', () => { - let mockEvent; - beforeEach(() => { - mockEvent = { - currentTarget: { - getAttribute: () => 0, - value: 'a', - }, - }; - }); - - it('updates state', () => { - component.instance().onChange(mockEvent); - expect(component.state('characters')[0]).toEqual('a'); - }); - }); -}); diff --git a/src/components/Setup/__tests__/ProtocolCard.test.js b/src/components/Setup/__tests__/ProtocolCard.test.js deleted file mode 100644 index 548884e218..0000000000 --- a/src/components/Setup/__tests__/ProtocolCard.test.js +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { UnconnectedProtocolCard as ProtocolCard } from '../ProtocolCard'; - -describe('', () => { - let component; - let mockProtocol; - let selectHandler; - let deleteHandler; - - beforeEach(() => { - selectHandler = jest.fn(); - deleteHandler = jest.fn(); - mockProtocol = { name: 'My Mock Protocol', description: 'Protocol description', schemaVersion: 2 }; - component = shallow(( - - )); - }); - - it('renders name & description', () => { - expect(component.text()).toMatch(mockProtocol.name); - expect(component.text()).toMatch(mockProtocol.description); - }); - - it('deletes on click delete', () => { - const mockEvent = { stopPropagation: () => {}, preventDefault: () => {} }; - component.find('.protocol-card__delete').simulate('click', mockEvent); - expect(deleteHandler).toHaveBeenCalledTimes(1); - }); - - it('starts a session when button is clicked', () => { - const mockEvent = { stopPropagation: () => {}, preventDefault: () => {} }; - component.find('.start-button').simulate('click', mockEvent); - expect(selectHandler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/Setup/__tests__/ServerAddressForm.test.js b/src/components/Setup/__tests__/ServerAddressForm.test.js deleted file mode 100644 index 2221d3c5d4..0000000000 --- a/src/components/Setup/__tests__/ServerAddressForm.test.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import { shallow } from 'enzyme'; - -import ServerAddressForm from '../ServerAddressForm'; - -const isSumbitButton = btn => btn.prop('type') !== 'reset' && btn.prop('type') !== 'button'; - -describe('', () => { - let component; - let selectHandler; - - beforeEach(() => { - selectHandler = jest.fn(); - component = shallow(); - }); - - it('allows selection', () => { - component.instance().setAddress('localhost'); - component.instance().setPort(9999); - component.simulate('submit', { preventDefault: jest.fn() }); - expect(selectHandler).toHaveBeenCalledTimes(1); - }); - - it('has a submit button that pairs', () => { - const submitButton = component.find('Button').filterWhere(isSumbitButton); - expect(submitButton).toHaveLength(1); - expect(submitButton.prop('content')).toEqual('Pair'); - }); - - it('has a cancel button', () => { - const cancelButton = component.find('Button').filterWhere(btn => !isSumbitButton(btn)); - expect(cancelButton).toHaveLength(1); - expect(cancelButton.children().text()).toEqual('Cancel'); - }); -}); diff --git a/src/components/Setup/__tests__/ServerPairingForm.test.js b/src/components/Setup/__tests__/ServerPairingForm.test.js deleted file mode 100644 index 7098a0b711..0000000000 --- a/src/components/Setup/__tests__/ServerPairingForm.test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-env jest */ -/* eslint-disable @codaco/spellcheck/spell-checker */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import ServerPairingForm from '../ServerPairingForm'; - -describe('', () => { - let component; - let completeHandler; - let mockSubmitEvt; - - beforeEach(() => { - completeHandler = jest.fn(); - mockSubmitEvt = { preventDefault: jest.fn() }; - component = shallow(); - }); - - it('renders a form', () => { - expect(component.find('form')).toHaveLength(1); - }); - - it('calls the completion handler', () => { - component.setState({ submittable: true }); - component.find('form').simulate('submit', mockSubmitEvt); - expect(completeHandler).toHaveBeenCalledTimes(1); - }); - - it('will not call the completion handler unless submittable', () => { - component.find('form').simulate('submit', mockSubmitEvt); - expect(completeHandler).not.toHaveBeenCalled(); - }); - - it('prevents default form submit', () => { - component.find('form').simulate('submit', mockSubmitEvt); - expect(mockSubmitEvt.preventDefault).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/Setup/__tests__/ServerProtocolList.test.js b/src/components/Setup/__tests__/ServerProtocolList.test.js deleted file mode 100644 index 20f8cd3c82..0000000000 --- a/src/components/Setup/__tests__/ServerProtocolList.test.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import { shallow } from 'enzyme'; - -import ServerProtocolList from '../ServerProtocolList'; - -describe('', () => { - let component; - let mockProtocols; - let downloadHandler; - - beforeEach(() => { - downloadHandler = jest.fn(); - mockProtocols = [{ name: 'my-mock-protocol', version: '1.0.1' }]; - component = shallow(( - - )); - }); - - it('renders a card for each protocol', () => { - expect(component.find('ProtocolCardMini')).toHaveLength(mockProtocols.length); - }); -}); diff --git a/src/components/Setup/index.js b/src/components/Setup/index.js deleted file mode 100644 index 475bfde949..0000000000 --- a/src/components/Setup/index.js +++ /dev/null @@ -1,12 +0,0 @@ -export { default as DiscoveredServerList } from './DiscoveredServerList'; -export { default as PairingCodeInput } from './PairingCodeInput'; -export { default as ProtocolCard } from './ProtocolCard'; -export { default as ProtocolCardMini } from './ProtocolCardMini'; -export { default as ServerProtocolList } from './ServerProtocolList'; -export { default as PairedServerWrapper } from './PairedServerWrapper'; -export { default as ServerAddressForm } from './ServerAddressForm'; -export { default as ServerPairingForm } from './ServerPairingForm'; -export { default as ServerSetup } from './ServerSetup'; -export { default as ServerCard } from './ServerCard'; -export { default as ServerUnavailable } from './ServerUnavailable'; -export { default as NewSessionOverlay } from '../../components/Setup/NewSessionOverlay'; diff --git a/src/containers/Setup/ExportProgressOverlay.js b/src/containers/Setup/ExportProgressOverlay.js deleted file mode 100644 index b756b30546..0000000000 --- a/src/containers/Setup/ExportProgressOverlay.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { Spinner, Button, Icon } from '@codaco/ui'; -import { ProgressBar, Scroller } from '../../components'; -import { actionCreators as exportSessionActions } from '../../ducks/modules/exportProcess'; - -class ExportProgressOverlay extends Component { - constructor(props) { - super(props); - - this.overlay = React.createRef(); - } - - handleUpdateCaseID(value) { - this.setState({ caseId: value }); - } - - render() { - const { - progress, - statusText, - errors, - sessionExportReset, - abort, - } = this.props; - - const iconName = errors.length > 0 ? 'warning' : 'tick'; - - const renderErrors = () => { - const errorList = errors.map((error, index) => (
  • {error}
  • )); - return ( -
    -

    Export finished with errors.

    -

    - Your export completed, but non-fatal errors were encountered during the process. This - may mean that not all sessions or all formats were able to be exported. - Review the details of these errors below, and ensure that you check the data you - received. -

    - Errors: - -
      {errorList}
    -
    -
    - ); - }; - - const renderStatus = () => ( -
    -

    {statusText}

    - -
    - ); - - return ( - -
    -
    - { progress === 100 ? ( - - ) : ( - - )} -
    - { errors.length > 0 && progress === 100 ? renderErrors() : renderStatus()} -
    -
    - { progress === 100 ? ( - - ) : ( - - )} -
    -
    - ); - } -} - -function mapDispatchToProps(dispatch) { - return { - sessionExportReset: bindActionCreators(exportSessionActions.sessionExportReset, dispatch), - }; -} -const mapStateToProps = state => ({ - progress: state.exportProcess.progress, - statusText: state.exportProcess.statusText, - errors: state.exportProcess.errors, -}); - -ExportProgressOverlay.propTypes = { - progress: PropTypes.number.isRequired, - statusText: PropTypes.string.isRequired, - errors: PropTypes.array.isRequired, - sessionExportReset: PropTypes.func.isRequired, - abort: PropTypes.func.isRequired, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ExportProgressOverlay); diff --git a/src/containers/Setup/ExportSessionsOverlay.js b/src/containers/Setup/ExportSessionsOverlay.js deleted file mode 100644 index 3ff9b9e5e7..0000000000 --- a/src/containers/Setup/ExportSessionsOverlay.js +++ /dev/null @@ -1,287 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { Button } from '@codaco/ui'; -import ProtocolUrlForm from './ProtocolUrlForm'; -import ServerPairing from './ServerPairing'; -import { ServerAddressForm, DiscoveredServerList } from '../../components/Setup'; -import { actionCreators as exportActions } from '../../ducks/modules/exportProcess'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import Overlay from '../Overlay'; -import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; -import PairedServerWrapper from '../../components/Setup/PairedServerWrapper'; -import ExportProgressOverlay from './ExportProgressOverlay'; - -class ExportSessionsOverlay extends PureComponent { - constructor(props) { - super(props); - this.state = { - selectedServer: null, // set when user selects/enters a server to pair with - previousSelectedServer: null, // selectedServer clone to populate manual inputs - exportMode: 'server', // used to switch between tabbed views - }; - - this.abortController = null; - } - - onPairingError() { - this.setState({ - // Make prev data available to repopulate manual form if needed - previousSelectedServer: this.state.selectedServer, - selectedServer: null, - }); - } - - onPairingComplete = () => { - this.setState({ - previousSelectedServer: null, - selectedServer: null, - }); - } - - get sessionsAreExportable() { - const { pairedServer } = this.props; - return pairedServer && pairedServer.secureServiceUrl; - } - - get exportSection() { - const exportFunc = this.state.exportMode === 'server' ? this.exportToServer : this.exportToFile; - - if (this.props.exportInProgress) { - return ( -
    - -
    - ); - } - - const destination = this.state.exportMode === 'server' ? this.props.pairedServer.name : 'file'; - const tabContent = ( - -
    -

    Ready to export

    -

    - Ready to export {this.props.sessionsToExport.length} session{this.props.sessionsToExport.length > 1 && ('s')} to {destination}. -

    -
    -
    -
    - -
    -
    -
    - ); - - return ( -
    - {this.state.exportMode === 'server' ? ( - - {tabContent} - - ) : ( -
    {tabContent}
    - )} -
    - ); - } - pairWithServer = (server) => { - this.setState({ selectedServer: server }); - } - - handleExportError = (additionalInformation) => { - const error = new Error(additionalInformation); - error.friendlyMessage = 'There was a problem downloading your data.'; - - this.props.openDialog({ - type: 'Error', - error, - confirmLabel: 'Okay', - }); - } - - exportToServer = (sessionList) => { - const exportPromise = this.props.exportToServer(sessionList.map((sessionId) => { - const session = this.props.sessions[sessionId]; - const sessionProtocolUID = session.protocolUID; - const sessionProtocol = this.props.installedProtocols[sessionProtocolUID]; - - return asNetworkWithSessionVariables( - sessionId, - session, - sessionProtocol, - ); - })); - - this.abortController = exportPromise.abort; - } - - exportToFile = (exportedSessions) => { - const exportPromise = this.props.exportToFile(exportedSessions.map((session) => { - const sessionProtocol = - this.props.installedProtocols[this.props.sessions[session].protocolUID]; - - return asNetworkWithSessionVariables( - session, - this.props.sessions[session], - sessionProtocol, - ); - })); - - this.abortController = exportPromise.abort; - }; - - switchTab = (exportMode) => { - if (exportMode === this.state.exportMode) return; - - this.setState({ exportMode }); - this.props.sessionExportReset(); - } - - contentAreas() { - const { - manualEntry, - previousSelectedServer: prev, - selectedServer, - exportMode, - } = this.state; - - const { - pairedServer, - } = this.props; - - let content; - - if (exportMode === 'url') { - content = ( - - ); - // If we are paired, show the server list, or exporting to file, show the session list - } else if (exportMode === 'download' || pairedServer) { - content = this.exportSection; - - // If user has requested manual entry show the form - } else if (manualEntry) { - content = ( - this.setState({ manualEntry: false })} - /> - ); - // If we have selected a server or have entered server connection details, attempt pairing - } else if (selectedServer && selectedServer.pairingServiceUrl) { - content = ( - - this.onPairingError()} - onCancel={() => this.setState({ selectedServer: null })} - /> - - ); - // Otherwise, we are on the server tab and should show the discovery view - } else { - content = ( - - -
    -
    -
    - ); - } - - // Renders the tabs for switching views - const tabContent = ( -
    -
    this.switchTab('server')} - > - Upload to Server -
    -
    this.switchTab('download')} - > - Export to File -
    -
    - ); - - return { tabContent, mainContent: content }; - } - - render() { - const { tabContent, mainContent } = this.contentAreas(); - return ( - - - {tabContent} -
    - {mainContent} -
    -
    -
    - ); - } -} - -ExportSessionsOverlay.defaultProps = { - pairedServer: null, - activeSession: null, -}; - -ExportSessionsOverlay.propTypes = { - show: PropTypes.bool.isRequired, - sessions: PropTypes.object.isRequired, - installedProtocols: PropTypes.object.isRequired, - onClose: PropTypes.func.isRequired, - sessionsToExport: PropTypes.array.isRequired, - openDialog: PropTypes.func.isRequired, - exportToServer: PropTypes.func.isRequired, - exportToFile: PropTypes.func.isRequired, - sessionExportReset: PropTypes.func.isRequired, - pairedServer: PropTypes.shape({ - pairingServiceUrl: PropTypes.string.isRequired, - name: PropTypes.string, - }), - exportInProgress: PropTypes.bool.isRequired, -}; - -const mapStateToProps = state => ({ - pairedServer: state.pairedServer, - sessions: state.sessions, - installedProtocols: state.installedProtocols, - activeSession: state.activeSessionId, - exportInProgress: state.exportProcess.progress > 0, -}); - -const mapDispatchToProps = dispatch => ({ - exportToServer: bindActionCreators(exportActions.exportToServer, dispatch), - exportToFile: bindActionCreators(exportActions.exportToFile, dispatch), - sessionExportReset: bindActionCreators(exportActions.sessionExportReset, dispatch), - removeSession: bindActionCreators(sessionsActions.removeSession, dispatch), - openDialog: bindActionCreators(dialogActions.openDialog, dispatch), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ExportSessionsOverlay); diff --git a/src/containers/Setup/ImportProgressOverlay.js b/src/containers/Setup/ImportProgressOverlay.js deleted file mode 100644 index 01251a51e9..0000000000 --- a/src/containers/Setup/ImportProgressOverlay.js +++ /dev/null @@ -1,79 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { Spinner, Modal, Button, Icon } from '@codaco/ui'; -import { ProgressBar } from '../../components'; -import { actionCreators as importProtocolActions } from '../../ducks/modules/importProtocol'; - -class ImportProgressOverlay extends Component { - constructor(props) { - super(props); - - this.overlay = React.createRef(); - this.handleImportFinished = this.handleImportFinished.bind(this); - } - - componentWillUpdate(newProps) { - if (newProps.progress.step === 6) { - this.handleImportFinished(); - } - } - - handleImportFinished() { - setTimeout(this.props.resetImportProtocol, 50000); - } - - handleUpdateCaseID(value) { - this.setState({ caseId: value }); - } - - - render() { - const { show, progress, resetImportProtocol } = this.props; - - const percentProgress = progress.step / 6; - - return ( - -
    -
    - { progress.step === 6 ? ( - - ) : ( - - )} -

    {progress.statusText}

    - { progress.step === 6 ? ( - - ) : ( - - - - - )} -
    -
    -
    - ); - } -} - -ImportProgressOverlay.propTypes = { - show: PropTypes.bool, - progress: PropTypes.object, - resetImportProtocol: PropTypes.func.isRequired, -}; - -ImportProgressOverlay.defaultProps = { - show: false, - progress: null, -}; - -function mapDispatchToProps(dispatch) { - return { - resetImportProtocol: bindActionCreators(importProtocolActions.resetImportProtocol, dispatch), - }; -} - -export default connect(null, mapDispatchToProps)(ImportProgressOverlay); diff --git a/src/containers/Setup/ProtocolImportOverlay.js b/src/containers/Setup/ProtocolImportOverlay.js deleted file mode 100644 index c54f500f17..0000000000 --- a/src/containers/Setup/ProtocolImportOverlay.js +++ /dev/null @@ -1,192 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { Button } from '@codaco/ui'; -import ProtocolUrlForm from './ProtocolUrlForm'; -import ServerPairing from './ServerPairing'; -import ServerProtocols from './ServerProtocols'; -import { ServerAddressForm, DiscoveredServerList } from '../../components/Setup'; -import importLocalProtocol from '../../utils/protocol/importLocalProtocol'; -import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; -import Overlay from '../Overlay'; - -/** - * This component is the top-level interface for protocol importing, wrapping - * dependent tasks such as server discovery and pairing. - * - * - A user selects (or manually enters the connection info of) an available server. - * - If pairing is required, then the pairing form is shown. - * - If or once a server is paired, a selectable list of protocols is shown. - */ -class ProtocolImportOverlay extends PureComponent { - constructor(props) { - super(props); - this.state = { - selectedServer: null, // set when user selects/enters a server to pair with - previousSelectedServer: null, // selectedServer clone to populate manual inputs - importMode: 'server', // used to switch between tabbed views - }; - } - - onPairingError() { - this.setState({ - // Make prev data available to repopulate manual form if needed - previousSelectedServer: this.state.selectedServer, - selectedServer: null, - }); - } - - onPairingComplete = () => { - this.setState({ - previousSelectedServer: null, - selectedServer: null, - }); - } - - onImportProtocol = (url, usePairedServer) => { - this.props.importProtocolFromURI(url, usePairedServer); - this.props.onClose(); - } - - pairWithServer = (server) => { - this.setState({ selectedServer: server }); - } - - contentAreas() { - const { - manualEntry, - previousSelectedServer: prev, - selectedServer, - importMode, - } = this.state; - - const { - pairedServer, - } = this.props; - - let content; - - // Show the URL form - if (importMode === 'url') { - content = ( - - ); - - // If we are paired, show the server list. - } else if (pairedServer) { - content = - ; - - // If user has requested manual entry show the form - } else if (manualEntry) { - content = ( - this.setState({ manualEntry: false })} - /> - ); - // If we have selected a server or have entered server connection details, attempt pairing - } else if (selectedServer && selectedServer.pairingServiceUrl) { - content = ( - - this.onPairingError()} - onCancel={() => this.setState({ selectedServer: null })} - /> - - ); - // Otherwise, we are on the server tab and should show the discovery view - } else { - content = ( - - -
    -
    -
    - ); - } - - // Renders the tabs for switching views - const tabContent = ( -
    -
    this.setState({ importMode: 'server' })} - > - From Server -
    -
    this.setState({ importMode: 'url' })} - > - From URL -
    -
    importLocalProtocol()} - > - Local file -
    -
    - ); - - return { tabContent, mainContent: content }; - } - - render() { - const { tabContent, mainContent } = this.contentAreas(); - return ( - - {tabContent} -
    - {mainContent} -
    -
    - ); - } -} - -ProtocolImportOverlay.defaultProps = { - pairedServer: null, -}; - -ProtocolImportOverlay.propTypes = { - pairedServer: PropTypes.object, - show: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - importProtocolFromURI: PropTypes.func.isRequired, -}; - -const mapStateToProps = state => ({ - pairedServer: state.pairedServer, -}); - -function mapDispatchToProps(dispatch) { - return { - importProtocolFromURI: - bindActionCreators(protocolActions.importProtocolFromURI, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ProtocolImportOverlay); - -export { - ProtocolImportOverlay as UnconnectedProtocolImportOverlay, -}; diff --git a/src/containers/Setup/ProtocolList.js b/src/containers/Setup/ProtocolList.js deleted file mode 100644 index cd0d6fabe4..0000000000 --- a/src/containers/Setup/ProtocolList.js +++ /dev/null @@ -1,143 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import Swiper from 'react-id-swiper'; -import { size, map } from 'lodash'; -import { Icon } from '@codaco/ui'; -import { NewSessionOverlay, ProtocolCard } from '../../components/Setup'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { actionCreators as importProtocolActions } from '../../ducks/modules/importProtocol'; -import { actionCreators as uiActions } from '../../ducks/modules/ui'; - -/** - * Display available protocols - */ -const ProtocolList = (props) => { - const { - installedProtocols, - activeSlideKey, - importProtocolFromURI, - addSession, - updateProtocolIndex, - } = props; - - const [showNewSessionOverlay, setShowNewSessionOverlay] = useState(false); - const [selectedProtocol, setSelectedProtocol] = useState(null); - - const onClickProtocolCard = (protocolUID) => { - setShowNewSessionOverlay(true); - setSelectedProtocol(protocolUID); - }; - - const handleCloseOverlay = () => { - setShowNewSessionOverlay(false); - setSelectedProtocol(null); - }; - - const handleCreateSession = (caseId) => { - addSession(caseId, selectedProtocol); - handleCloseOverlay(); - }; - - const handleSwipe = (index) => { - updateProtocolIndex(index); - }; - - const params = { - containerClass: 'protocol-list swiper-container', - pagination: {}, - navigation: { - nextEl: '.swiper-button-next', - prevEl: '.swiper-button-prev', - }, - renderPrevButton: () => , - renderNextButton: () => , - loop: false, - slidesPerView: 'auto', - centeredSlides: true, - shouldSwiperUpdate: true, - initialSlide: activeSlideKey, - }; - - const installedProtocolsArray = - Object.keys(installedProtocols).map( - protocol => ({ ...installedProtocols[protocol], uuid: protocol })); - - return ( - - { size(installedProtocols) > 0 ? - { - swiper.on('slideChange', () => { handleSwipe(swiper.activeIndex); }); - }} - > - { map(installedProtocolsArray, (protocol, index) => ( -
    - onClickProtocolCard(protocol.uuid)} - /> -
    - )) } -
    - : -
    -
    -

    No interview protocols installed

    -

    - To get started, install an interview protocol on this device. To do this, - click the button in the bottom right to pair with an instance of Server, - import a protocol from a URL, or add a local .netcanvas file. -

    -

    Alternatively, click importProtocolFromURI('https://documentation.networkcanvas.com/protocols/Public%20Health%20Protocol%20schema%202.netcanvas')}>here to download and install a sample public health protocol (requires network access).

    -
    -
    - } - -
    - ); -}; - -ProtocolList.propTypes = { - addSession: PropTypes.func.isRequired, - loadSession: PropTypes.func.isRequired, - installedProtocols: PropTypes.object.isRequired, -}; - -ProtocolList.defaultProps = { -}; - -function mapStateToProps(state) { - return { - installedProtocols: state.installedProtocols, - importProtocolStatus: state.importProtocol, - activeSlideKey: state.ui.protocolIndex, - }; -} - -function mapDispatchToProps(dispatch) { - return { - addSession: bindActionCreators(sessionsActions.addSession, dispatch), - loadSession: bindActionCreators(sessionsActions.loadSession, dispatch), - openDialog: bindActionCreators(dialogActions.openDialog, dispatch), - importProtocolFromURI: - bindActionCreators(importProtocolActions.importProtocolFromURI, dispatch), - resetImportProtocol: bindActionCreators(importProtocolActions.resetImportProtocol, dispatch), - updateProtocolIndex: (index) => { - dispatch(uiActions.update({ - protocolIndex: index, - })); - }, - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ProtocolList); - -export { ProtocolList as UnconnectedProtocolList }; diff --git a/src/containers/Setup/ProtocolUrlForm.js b/src/containers/Setup/ProtocolUrlForm.js deleted file mode 100644 index 70024e79e4..0000000000 --- a/src/containers/Setup/ProtocolUrlForm.js +++ /dev/null @@ -1,59 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '@codaco/ui'; -import Form from '../Form'; - -const formConfig = { - formName: 'setup', - fields: [ - { - label: 'Protocol URL', - name: 'protocol_url', - component: 'Text', - placeholder: 'Protocol URL', - required: true, - }, - ], -}; - -const initialValues = { - protocol_url: 'https://', -}; - -class ProtocolUrlForm extends Component { - onClickImportRemoteProtocol = (fields) => { - if (fields) { - this.props.importProtocolFromURI(fields.protocol_url); - } - } - - render() { - return ( - -

    - Enter the full URL to a protocol file below, including http:// or https:// at the start. -

    -
    -
    - -
    -
    -
    - - ); - } -} - -ProtocolUrlForm.propTypes = { - importProtocolFromURI: PropTypes.func.isRequired, -}; - -export default ProtocolUrlForm; diff --git a/src/containers/Setup/ServerPairing.js b/src/containers/Setup/ServerPairing.js deleted file mode 100644 index c4bd8c31d1..0000000000 --- a/src/containers/Setup/ServerPairing.js +++ /dev/null @@ -1,139 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; - -import ApiClient from '../../utils/ApiClient'; -import { addSecureApiUrlToServer } from '../../utils/serverAddressing'; -import { ServerSetup, ServerPairingForm } from '../../components/Setup'; -import { actionCreators } from '../../ducks/modules/pairedServer'; - -const emptyState = Object.freeze({ - loading: false, - pairingCode: null, - pairingRequestId: null, -}); - -/** - * This component is responsible for pairing with a server selected or entered by the user - */ -class ServerPairing extends Component { - constructor(props) { - super(props); - this.state = emptyState; - } - - componentDidMount() { - this.apiClient = new ApiClient(this.props.server.pairingServiceUrl); - this.requestPairingCode(); - } - - componentWillUnmount() { - this.apiClient.cancelAll(); - } - - handleApiError(err) { - this.props.pairingFailed(err); - this.setState(emptyState); - this.props.onError(err); - } - - requestPairingCode() { - this.setState({ loading: true }); - this.apiClient.requestPairing() - .then((data) => { - if (!data) { - // we aborted the request during unmount - return; - } - this.setState({ - loading: false, - pairingRequestId: data.pairingRequestId, - pairingRequestSalt: data.salt, - }); - }) - .catch(err => this.handleApiError(err)); - } - - // Pairing step 2: derive a secret, send (encrypted) to server - // At this point, we have a known connection to LAN server. 'loading' state would only distract. - confirmPairing = () => { - const { pairingCode, pairingRequestId, pairingRequestSalt } = this.state; - const { deviceName } = this.props; - this.apiClient.confirmPairing(pairingCode, pairingRequestId, pairingRequestSalt, deviceName) - .then((pairingInfo) => { - const device = pairingInfo.device; - const server = addSecureApiUrlToServer({ - ...this.props.server, - securePort: pairingInfo.securePort, - sslCertificate: pairingInfo.sslCertificate, - }); - this.setState(emptyState); - this.props.setPairedServer(server, device.id, device.secret); - }) - .then(() => this.props.onComplete()) - .catch(err => this.handleApiError(err)); - } - - completePairing = (pairingCode) => { - this.setState( - { pairingCode }, - () => setTimeout(this.confirmPairing, 0), - ); - } - - render() { - const { server, onCancel } = this.props; - const { loading } = this.state; - - return ( - - { - - } - - ); - } -} - -ServerPairing.defaultProps = { - deviceName: '', - onComplete: () => {}, - onError: () => {}, -}; - -ServerPairing.propTypes = { - deviceName: PropTypes.string, - onComplete: PropTypes.func, - onError: PropTypes.func, - pairingFailed: PropTypes.func.isRequired, - server: PropTypes.shape({ - pairingServiceUrl: PropTypes.string.isRequired, - host: PropTypes.string, - }).isRequired, - setPairedServer: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, -}; - -function mapStateToProps(state) { - return { - deviceName: state.deviceSettings.description, - isFactory: state.importProtocol.isFactory, - }; -} - -function mapDispatchToProps(dispatch) { - return { - setPairedServer: bindActionCreators(actionCreators.setPairedServer, dispatch), - pairingFailed: bindActionCreators(actionCreators.pairingFailed, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ServerPairing); - -export { ServerPairing as UnconnectedServerPairing }; diff --git a/src/containers/Setup/ServerProtocols.js b/src/containers/Setup/ServerProtocols.js deleted file mode 100644 index f074218bb4..0000000000 --- a/src/containers/Setup/ServerProtocols.js +++ /dev/null @@ -1,94 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import ApiClient from '../../utils/ApiClient'; -import { ServerProtocolList, ServerSetup, PairedServerWrapper, ServerUnavailable } from '../../components/Setup'; - - -/** - * @class - * Renders a list of protocols, from which a user can choose to download. - */ -class ServerProtocols extends Component { - constructor(props) { - super(props); - this.state = {}; - } - - componentDidMount() { - const { pairedServer } = this.props; - this.apiClient = new ApiClient(pairedServer); - this.fetchProtocolList(); - } - - handleApiError(err) { - this.setState({ error: err }); - } - - fetchProtocolList = () => { - this.apiClient - .addTrustedCert() - .then(() => this.apiClient.getProtocols()) - .then(protocols => this.setState({ protocols })) - .catch(err => this.handleApiError(err)); - } - - handleRetry = () => { - this.setState({ error: null }, this.fetchProtocolList); - } - - handleSelectProtocol = (protocol) => { - this.props.importProtocolFromURI(protocol.downloadPath, true); - } - - render() { - const { error, protocols } = this.state; - const { server } = this.props; - - let content = null; - - if (protocols) { - content = ( - - ); - } // else still loading - - return ( - - { error ? - ( - - ) : ( - - {content} - - ) - } - - ); - } -} - -ServerProtocols.propTypes = { - importProtocolFromURI: PropTypes.func.isRequired, - pairedServer: PropTypes.object.isRequired, - server: PropTypes.shape({ - pairingServiceUrl: PropTypes.string.isRequired, - }).isRequired, -}; - -function mapStateToProps(state) { - return { - pairedServer: state.pairedServer, - }; -} - -export default connect(mapStateToProps)(ServerProtocols); - -export { ServerProtocols as UnconnectedServerProtocols }; diff --git a/src/containers/Setup/SessionListContainer.js b/src/containers/Setup/SessionListContainer.js deleted file mode 100644 index a4a417e658..0000000000 --- a/src/containers/Setup/SessionListContainer.js +++ /dev/null @@ -1,210 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { isEmpty, get } from 'lodash'; -import { Button } from '@codaco/ui'; -import { Toggle } from '@codaco/ui/lib/components/Fields'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; -import { actionCreators as exportActions } from '../../ducks/modules/exportProcess'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { FilterableListWrapper, SessionList, NodeBin } from '../../components'; -import { entityAttributesProperty } from '../../ducks/modules/network'; -import { ExportSessionsOverlay } from '.'; - -const displayDate = timestamp => timestamp && new Date(timestamp).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); - -const oneBasedIndex = i => parseInt(i || 0, 10) + 1; - -const emptyView = ( -
    -
    -

    No previous interviews found

    -

    - You have no in-progress interview sessions available to resume. - To begin a new session, select a protocol from the main start screen. -

    -
    -
    -); - -/** - * Display stored sessions - */ -class SessionListContainer extends Component { - constructor(props) { - super(props); - - this.state = { - showExportSessionsOverlay: false, - selectedSessions: [], - }; - } - - onSelectSession = (session) => { - if (this.isSelected(session.uuid)) { - this.setState({ - selectedSessions: this.state.selectedSessions.filter(item => item !== session.uuid), - }); - } else { - this.setState({ - selectedSessions: [...this.state.selectedSessions, session.uuid], - }); - } - } - - onDeleteCard = (uuid) => { - this.props.openDialog({ - type: 'Warning', - title: 'Delete this interview session?', - confirmLabel: 'Delete session', - onConfirm: () => this.props.removeSession(uuid), - message: ( -

    - This action will delete this interview session, and cannot be undone. Continue? -

    - ), - }); - }; - - isSelected = uuid => this.state.selectedSessions.includes(uuid); - - render() { - const { installedProtocols, sessions, sessionExportReset } = this.props; - // Display most recent first, and filter out any session that doesn't have a protocol - const sessionList = Object.keys(sessions) - .map(key => ({ uuid: key, [entityAttributesProperty]: sessions[key] })); - - if (isEmpty(sessionList)) { - return emptyView; - } - - return ( - - { - this.setState({ - showExportSessionsOverlay: false, - selectedSessions: [], - }); - - sessionExportReset(); - }} - sessionsToExport={this.state.selectedSessions} - /> -
    - sessionInfo[entityAttributesProperty].caseId, - onItemSelect: this.onSelectSession, - isItemSelected: item => this.isSelected(item.uuid), - getKey: sessionInfo => sessionInfo.uuid, - progress: (sessionInfo) => { - const session = sessionInfo[entityAttributesProperty]; - const protocolStages = get(installedProtocols, [session.protocolUID, 'stages'], []); - const numberOfProtocolStages = oneBasedIndex(protocolStages.length); - - return Math.round( - (oneBasedIndex(session.stageIndex) / numberOfProtocolStages) * 100, - ); - }, - details: (sessionInfo) => { - const session = sessionInfo[entityAttributesProperty]; - const exportedAt = session.lastExportedAt; - const exportedDisplay = exportedAt ? displayDate(exportedAt) : 'never'; - const protocol = installedProtocols[session.protocolUID] || {}; - const protocolLabel = protocol.name || '[version out of date]'; - return [ - { Modified: displayDate(session.updatedAt) }, - { Protocol: protocolLabel }, - { Exported: exportedDisplay }, - ]; - }, - }} - items={sessionList} - initialSortOrder={[{ - property: 'updatedAt', - direction: 'desc', - }]} - sortFields={[ - { - label: 'Last Changed', - variable: 'updatedAt', - }, - { - label: 'Case ID', - variable: 'caseId', - }, - { - label: 'Progress', - variable: 'stageIndex', - }, - ]} - /> - { this.state.selectedSessions.length > 0 && -
    -
    -

    {this.state.selectedSessions.length} Selected

    - { - if (this.state.selectedSessions.length === sessionList.length) { - this.setState({ - selectedSessions: [], - }); - } else { - this.setState({ - selectedSessions: sessionList.map(session => session.uuid), - }); - } - }, - }} - label="Select all" - /> -
    - -
    - } - true} - dropHandler={meta => this.onDeleteCard(meta.uuid)} - id="NODE_BIN" - /> -
    -
    - ); - } -} - -SessionListContainer.propTypes = { - installedProtocols: PropTypes.object.isRequired, - removeSession: PropTypes.func.isRequired, - sessions: PropTypes.object.isRequired, - openDialog: PropTypes.func.isRequired, -}; - -function mapStateToProps(state) { - return { - installedProtocols: state.installedProtocols, - sessions: state.sessions, - }; -} - -function mapDispatchToProps(dispatch) { - return { - removeSession: bindActionCreators(sessionsActions.removeSession, dispatch), - sessionExportReset: bindActionCreators(exportActions.sessionExportReset, dispatch), - openDialog: bindActionCreators(dialogActions.openDialog, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SessionListContainer); diff --git a/src/containers/Setup/SetupScreen.js b/src/containers/Setup/SetupScreen.js deleted file mode 100644 index dfd2d4bad3..0000000000 --- a/src/containers/Setup/SetupScreen.js +++ /dev/null @@ -1,122 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { get } from 'lodash'; -import { Redirect } from 'react-router-dom'; -import { withRouter } from 'react-router'; -import { connect } from 'react-redux'; -import cx from 'classnames'; -import { Icon } from '@codaco/ui'; -import { isIOS } from '../../utils/Environment'; -import projectLogo from '../../images/project-logo.svg'; -import betaProjectLogo from '../../images/project-logo-beta.svg'; -import downArrow from '../../images/down-arrow.svg'; -import SettingsMenuButton from '../../components/SettingsMenu/SettingsMenuButton'; -import { ProtocolList, ProtocolImportOverlay, SessionListContainer, ImportProgressOverlay } from '.'; - -/** - * Setup screen - * @extends Component - */ -class Setup extends Component { - constructor(props) { - super(props); - this.state = { - showSessionOverlay: false, - showImportProtocolOverlay: false, - }; - } - - render() { - if (this.props.isSessionActive) { - const stageIndex = this.props.sessions[this.props.sessionId].stageIndex; - const pathname = `/session/${this.props.sessionId}/${stageIndex}`; - return (); - } - - const resumeOverlayClassnames = cx( - 'resume-session-panel', - { 'resume-session-panel--open': this.state.showSessionOverlay }, - ); - - const setupClassnames = cx( - 'setup', - { 'setup--show-sessions': this.state.showSessionOverlay }, - { 'setup--show-protocols': !this.state.showSessionOverlay }, - ); - - return ( - -
    - this.setState({ showImportProtocolOverlay: false })} - /> - 0} - progress={this.props.importProtocolProgress} - /> -
    - { !this.state.showSessionOverlay && - () - } -
    - Network Canvas -
    -
    - -
    -
    -
    -
    this.setState({ showSessionOverlay: !this.state.showSessionOverlay })}> -
    - Resume interview -

    - { !this.state.showSessionOverlay ? - ('Manage Interview Sessions') : - ('Start New Session') - } -

    -
    - -
    - -
    - { !this.state.showSessionOverlay && - ( - this.setState({ showImportProtocolOverlay: true })} - /> - )} - - ); - } -} - -Setup.propTypes = { - isSessionActive: PropTypes.bool.isRequired, - sessionId: PropTypes.string, - sessions: PropTypes.object.isRequired, - importProtocolProgress: PropTypes.object.isRequired, -}; - -Setup.defaultProps = { - isPairedWithServer: false, - sessionId: null, -}; - -function mapStateToProps(state) { - return { - isSessionActive: !!state.activeSessionId, - isPairedWithServer: !!state.pairedServer, - protocolUID: get(state.sessions[state.activeSessionId], 'protocolUID'), - sessionId: state.activeSessionId, - sessions: state.sessions, - importProtocolProgress: state.importProtocol, - }; -} - -export default withRouter(connect(mapStateToProps)(Setup)); - -export { Setup as UnconnectedSetup }; diff --git a/src/containers/Setup/index.js b/src/containers/Setup/index.js deleted file mode 100644 index 009bbb4ea0..0000000000 --- a/src/containers/Setup/index.js +++ /dev/null @@ -1,8 +0,0 @@ -export { default as ProtocolImportOverlay } from './ProtocolImportOverlay'; -export { default as ExportSessionsOverlay } from './ExportSessionsOverlay'; -export { default as ProtocolUrlForm } from './ProtocolUrlForm'; -export { default as SetupScreen } from './SetupScreen'; -export { default as ProtocolList } from './ProtocolList'; -export { default as SessionListContainer } from './SessionListContainer'; -export { default as ImportProgressOverlay } from './ImportProgressOverlay'; - diff --git a/src/routes.js b/src/routes.js index 1ab43a1984..5bb8ae2445 100644 --- a/src/routes.js +++ b/src/routes.js @@ -12,7 +12,8 @@ import { ProtocolScreen, } from './containers'; -import { SetupScreen } from './containers/Setup'; + +const SetupScreen = () => (

    Hello

    ); function mapStateToProps(state) { return { diff --git a/src/styles/components/_all.scss b/src/styles/components/_all.scss index d929b3a5e9..4db518c307 100644 --- a/src/styles/components/_all.scss +++ b/src/styles/components/_all.scss @@ -4,7 +4,6 @@ @import 'convex-hull'; @import 'input'; @import 'node-list'; -@import 'session-list'; @import 'prompts'; @import 'node-bin'; @import 'pips'; @@ -15,21 +14,12 @@ @import 'pairing-code-input'; @import 'panels'; @import 'panel'; -@import 'protocol'; -@import 'protocol-card'; -@import 'protocol-card-mini'; @import 'stage'; @import 'scrollable'; @import 'drop-zone'; -@import 'pairing-form'; @import 'preset-switcher'; @import 'preset-switcher-key'; @import 'search'; -@import 'server-address-form'; -@import 'server-card'; -@import 'server-protocol-list'; -@import 'server-setup'; -@import 'server-unavailable'; @import 'canvas'; @import 'concentric-circles'; @import 'sociogram-background'; diff --git a/src/styles/components/_pairing-form.scss b/src/styles/components/_pairing-form.scss deleted file mode 100644 index e108c87a40..0000000000 --- a/src/styles/components/_pairing-form.scss +++ /dev/null @@ -1,19 +0,0 @@ -.pairing-form { - &__submit { - margin-left: 1.5rem; - } - - .button { - &[disabled] { - cursor: default; - opacity: .5; - } - } - - &__loading { - align-items: center; - display: flex; - justify-content: center; - margin: unit(4); - } -} diff --git a/src/styles/components/_protocol-card-mini.scss b/src/styles/components/_protocol-card-mini.scss deleted file mode 100644 index 6f342f0772..0000000000 --- a/src/styles/components/_protocol-card-mini.scss +++ /dev/null @@ -1,36 +0,0 @@ -$component: 'protocol-card-mini'; - -.#{$component} { - align-items: center; - background-color: var(--color-platinum); - border-radius: var(--border-radius); - cursor: pointer; - display: flex; - flex-direction: row; - padding: unit(2); - width: 100%; - border-bottom: .5rem solid var(--color-platinum--dark); - color: var(--color-navy-taupe); - margin-bottom: unit(2); - - &__description { - font-size: 80%; - } - - &__icon-wrapper { - flex-basis: 25%; - text-align: center; - } - - &__content { - flex-basis: 75%; - flex-shrink: 1; - } - - &__icon { - height: 5rem; - width: 5rem; - text-align: center; - } - -} diff --git a/src/styles/components/_protocol-card.scss b/src/styles/components/_protocol-card.scss deleted file mode 100644 index c984f8b670..0000000000 --- a/src/styles/components/_protocol-card.scss +++ /dev/null @@ -1,130 +0,0 @@ -$component: 'protocol-card'; - -.#{$component} { - position: relative; - background-color: var(--color-platinum); - border-radius: var(--border-radius); - padding: unit(6) unit(4) unit(4); - width: 24rem; - border-bottom: .75rem solid var(--color-platinum--dark); - color: var(--color-charcoal); - height: 100%; - max-height: 30rem; - box-sizing: border-box; - - &--warning { - border-bottom-color: var(--warning); - } - - &--info { - border-bottom-color: var(--info); - } - - &--error { - border-bottom-color: var(--error); - } - - &__name { - // sass-lint:disable-block no-vendor-prefixes - /* autoprefixer: off */ - -webkit-box-orient: vertical; - /* autoprefixer: on */ - display: -webkit-box; - -webkit-line-clamp: 2; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; - height: 2.9rem; - } - - &__description { - height: calc(100% - 10rem); - overflow-y: auto; - font-weight: normal; - } - - &__delete { - position: absolute; - top: unit(1.5); - right: unit(1.5); - padding: unit(.5); - cursor: pointer; - - .icon { - height: unit(1.5); - - .cls-1 { - fill: var(--text--dark); - } - } - } - - .start-button { - background: var(--color-platinum--dark); - border-radius: 5rem; - display: flex; - cursor: pointer; - align-items: center; - box-shadow: 0 0.15rem rgba(0, 0, 0, 0.25); //sass-lint:disable-line no-color-literals - height: 5rem; - - &--disabled { - opacity: 0.5; - filter: saturate(0); - pointer-events: none; - } - - &__text { - font-weight: 500; - } - - &__protocol-icon { - height: 5rem; - width: 6rem; - text-align: center; - } - - &__arrow { - width: 4rem; - height: 1rem; - - .cls-2 { - fill: var(--color-charcoal); - } - } - } - - &__info, - &__warning, - &__error { - position: absolute; - top: unit(-2); - left: unit(-2); - height: unit(8); - width: unit(8); - border-radius: 50%; - cursor: pointer; - - .icon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - height: unit(4); - width: unit(4); - } - } - - &__info { - background: var(--info); - } - - &__warning, - &__error { - background: var(--warning); - } - - &__error { - background: var(--error); - } -} diff --git a/src/styles/components/_protocol.scss b/src/styles/components/_protocol.scss deleted file mode 100644 index 61db2d5e26..0000000000 --- a/src/styles/components/_protocol.scss +++ /dev/null @@ -1,11 +0,0 @@ -.protocol { - display: flex; - width: 100vw; - height: 100%; - - &__content { - background-color: palette('background'); - flex: 1 1 auto; - height: 100%; - } -} diff --git a/src/styles/components/_server-address-form.scss b/src/styles/components/_server-address-form.scss deleted file mode 100644 index ed06fe37d3..0000000000 --- a/src/styles/components/_server-address-form.scss +++ /dev/null @@ -1,13 +0,0 @@ -.server-address-form { - &__fields { - margin-bottom: unit(2); - } - - &__field { - display: inline-block; - } - - &__submit { - margin-left: 1.5rem; - } -} diff --git a/src/styles/components/_server-card.scss b/src/styles/components/_server-card.scss deleted file mode 100644 index 6e48e185cb..0000000000 --- a/src/styles/components/_server-card.scss +++ /dev/null @@ -1,47 +0,0 @@ -.server-card { - margin: unit(2); - width: 8rem; - text-align: center; - - img { - width: 6rem; - } - - button { - margin-top: 1rem; - } - - &__label { - text-align: center; - margin: 0; - margin-top: .5rem; - overflow-wrap: break-word; - } - - &--clickable { - cursor: pointer; - } - - &--error { - .server-card__icon { - filter: grayscale(100%); - } - } -} - -.server-card-wrapper { - display: flex; - - .server-card { - width: 10rem; - margin: 0 unit(2) 0 0; - } - - .server-card-container { - justify-content: space-between; - flex: 1; - display: flex; - flex-direction: column; - } - -} diff --git a/src/styles/components/_server-protocol-list.scss b/src/styles/components/_server-protocol-list.scss deleted file mode 100644 index f771270890..0000000000 --- a/src/styles/components/_server-protocol-list.scss +++ /dev/null @@ -1,17 +0,0 @@ -.server-protocol-list { - display: flex; - flex: auto; - flex-direction: row; - margin-bottom: -1.5rem; - max-height: 25rem; - - &--empty { - padding-top: $scroller-top-padding; - } - - &__scroller { - padding: 1.5rem 0; - position: relative; - } - -} diff --git a/src/styles/components/_server-setup.scss b/src/styles/components/_server-setup.scss deleted file mode 100644 index 77835b5ba8..0000000000 --- a/src/styles/components/_server-setup.scss +++ /dev/null @@ -1,21 +0,0 @@ -.server-setup { - display: flex; - flex-direction: column; - height: 100%; - - &__server { - align-items: center; - display: flex; - flex-direction: row; - } - - &__card { - flex-grow: 0; - flex-shrink: 0; - } - - &__form { - flex: auto; - padding-left: spacing(large); - } -} diff --git a/src/styles/components/_server-unavailable.scss b/src/styles/components/_server-unavailable.scss deleted file mode 100644 index bf8c13d275..0000000000 --- a/src/styles/components/_server-unavailable.scss +++ /dev/null @@ -1,4 +0,0 @@ -.server-unavailable { - padding-left: 2rem; - width: 100%; -} diff --git a/src/styles/components/_session-list.scss b/src/styles/components/_session-list.scss deleted file mode 100644 index 69d11ce3ae..0000000000 --- a/src/styles/components/_session-list.scss +++ /dev/null @@ -1,174 +0,0 @@ -.session-list { - flex-wrap: wrap; - display: grid; - grid-template-columns: 1fr 1fr; - grid-gap: 2rem 3rem; - padding: unit(4); - - @media screen and (min-aspect-ratio: 4 / 3) { - grid-template-columns: 1fr 1fr 1fr; - } - - @media screen and (min-aspect-ratio: 16 / 10) { - grid-template-columns: 1fr 1fr 1fr; - } - - @media screen and (min-aspect-ratio: 16 / 9) { - grid-template-columns: 1fr 1fr 1fr; - } - -} - -.session-card { - @include transition-properties((background-color, border-bottom-color, transform, box-shadow), var(--animation-easing), var(--animation-duration-standard)); - align-items: center; - background-color: var(--color-platinum); - border-radius: var(--border-radius); - display: flex; - flex-direction: column; - padding: unit(2); - height: 100%; - border-bottom: 0.5rem solid var(--color-platinum--dark); - color: var(--color-navy-taupe); - justify-content: center; - min-width: 20rem; - - &--selected { - background-color: var(--color-platinum--dark); - border-bottom-color: var(--color-mustard); - transform: scale(1.05); - box-shadow: 1rem 1rem 1rem rgba(0, 0, 0, 0.2); // sass-lint:disable-line no-color-literals - - .session-card__content { - .progress-wrapper { - .progress-bar { - background-color: var(--color-white); - } - } - } - } - - &__content { - display: flex; - align-items: center; - width: 100%; - - .progress-wrapper { - height: 8rem; - margin: 0 auto; - display: flex; - align-items: center; - flex-direction: column; - flex-basis: 20%; - - .progress-bar { - height: 100%; - background-color: var(--color-platinum--dark); - - .progress-bar__filler { - background-color: var(--color-slate-blue); - } - - &--complete { - .progress-bar__filler { - background-color: var(--primary); - } - } - } - - h6 { - margin-bottom: 0; - margin-top: 0.5rem; - } - } - - .card__attributes { - width: 80%; - align-self: flex-start; - - .card__label { - word-break: break-all; - font-size: 1.45rem; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - -} - -.session-list-container { - height: 100%; - width: 100%; - - &__wrapper { - width: 90vw; - height: calc(80vh); - margin: 0 auto; - background: var(--session-management-screen-bg); - border-radius: var(--border-radius); - display: flex; - flex-direction: column; - overflow: hidden; - - .list-select { - flex-grow: 1; - flex-shrink: 1; - - .list-select__sort { - padding: unit(2) unit(2) 0; - } - } - - } - - &__selected { - background: var(--color-slate-blue); - padding: unit(2) unit(4); - flex-basis: 8rem; - display: flex; - align-items: center; - justify-content: space-between; - - .selected__info { - display: flex; - align-items: center; - - .form-field-container { - margin: 0; - - .form-field { - background: none; - margin: 0; - } - } - - h1 { - margin-right: 1rem; - } - } - } - - &--empty { - display: flex; - justify-content: center; - width: 100%; - height: 70%; - align-items: center; - - .getting-started { - padding: unit(6); - background: var(--light-background); - display: flex; - flex-direction: column; - width: 60vw; - border-radius: var(--border-radius); - - &__header { - text-align: center; - } - } - } -} diff --git a/src/styles/containers/_all.scss b/src/styles/containers/_all.scss index a194d1d919..a327fc55ba 100644 --- a/src/styles/containers/_all.scss +++ b/src/styles/containers/_all.scss @@ -6,11 +6,6 @@ @import 'name-generator-auto-complete-interface'; @import 'ordinal-bin-interface'; @import 'categorical-bin-interface'; -@import 'setupScreen'; -@import 'protocol-import-overlay'; -@import 'session-export-overlay'; -@import 'protocol-list'; -@import 'server-list'; @import 'sociogram-interface'; @import 'narrative-interface'; @import 'alter-form-interface'; diff --git a/src/styles/containers/_protocol-import-overlay.scss b/src/styles/containers/_protocol-import-overlay.scss deleted file mode 100644 index ba01e7c6fb..0000000000 --- a/src/styles/containers/_protocol-import-overlay.scss +++ /dev/null @@ -1,66 +0,0 @@ -.protocol-import-dialog { - max-width: 65rem; - max-height: none; - width: 80vw; - - &__tabs { - background: var(--background); - border-radius: var(--border-radius); - display: flex; - overflow: hidden; - - .tab { - @include vcjc; // vertical center, justify center - display: flex; - flex-grow: 1; - padding: unit(2); - font-weight: 700; - font-size: 0.8rem; - letter-spacing: 0.15em; - text-transform: uppercase; - cursor: pointer; - text-align: center; - - &--selected { - background: var(--primary); - } - } - } - - .dialog__additional-box { - height: auto; - } - - .dialog__main-content { - flex-grow: unset; - } -} - - -.protocol-import { - &__content { - background: var(--background); - padding: 2rem; - border-radius: var(--border-radius); - margin-top: 1rem; - - code { - background: var(--color-slate-blue); - padding: 0.45rem; - border-radius: var(--border-radius); - } - } - - &--footer { - display: flex; - justify-content: space-between; - flex-direction: row-reverse; - align-items: baseline; - } - - &__buttons { - .button { - margin-left: spacing(small); - } - } -} diff --git a/src/styles/containers/_protocol-list.scss b/src/styles/containers/_protocol-list.scss deleted file mode 100644 index 6cc028dfa4..0000000000 --- a/src/styles/containers/_protocol-list.scss +++ /dev/null @@ -1,19 +0,0 @@ -.protocol-list { - height: 100%; - width: 100%; - - &--empty { - display: flex; - justify-content: center; - width: 100%; - height: auto; - - .getting-started { - padding: unit(3) unit(6); - background: var(--light-background); - flex-direction: column; - width: 60vw; - border-radius: var(--border-radius); - } - } -} diff --git a/src/styles/containers/_server-list.scss b/src/styles/containers/_server-list.scss deleted file mode 100644 index 16c2cb7eb0..0000000000 --- a/src/styles/containers/_server-list.scss +++ /dev/null @@ -1,32 +0,0 @@ -.server-list { - align-items: center; - display: flex; - height: 100%; - justify-content: center; - overflow-y: auto; - - - &--available { - display: block; - } - - &__content { - display: grid; - grid-template-columns: repeat(4, 1fr); - max-height: 20rem; - padding: 2rem 0; - grid-gap: 3rem; - - .server-card { - margin: 0; - } - } - - &__placeholder { - text-align: center; - padding: unit(2) 0 unit(4); - display: flex; - flex-direction: column; - align-items: center; - } -} diff --git a/src/styles/containers/_session-export-overlay.scss b/src/styles/containers/_session-export-overlay.scss deleted file mode 100644 index fe8526674b..0000000000 --- a/src/styles/containers/_session-export-overlay.scss +++ /dev/null @@ -1,137 +0,0 @@ -.session-export-overlay { - max-width: 65rem; - max-height: none; - width: 80vw; - - &__tabs { - background: var(--background); - border-radius: var(--border-radius); - display: flex; - overflow: hidden; - - .tab { - @include vcjc; // vertical center, justify center - display: flex; - flex-grow: 1; - padding: unit(2); - font-weight: 700; - font-size: 0.8rem; - letter-spacing: 0.15em; - text-transform: uppercase; - cursor: pointer; - text-align: center; - - &--selected { - background: var(--primary); - } - } - } -} - - -.session-export { - &__content { - background: var(--background); - border-radius: var(--border-radius); - margin-top: 1rem; - padding: 1.2rem; - - code { - background: var(--color-slate-blue); - padding: 0.45rem; - border-radius: var(--border-radius); - } - } - - &--footer { - display: flex; - justify-content: space-between; - flex-direction: row-reverse; - align-items: baseline; - } - - &__buttons { - .button { - margin-left: spacing(small); - } - } -} - -.session-export-content { - display: flex; - flex-direction: column; -} - -.file-export { - &__footer { - display: flex; - justify-content: flex-end; - } -} - -.export-progress-overlay { - display: flex; - flex: 1 0 auto; - max-height: 30rem; - - .error-list { - margin: 0; - padding: 0; - - li { - list-style: none; - display: flex; - align-items: center; - margin: 0.75rem; - - .icon { - height: unit(3); - width: unit(3); - margin-right: unit(2); - } - } - } - - &__icon { - --circle-size: 1.5rem; - - display: flex; - justify-content: center; - align-items: center; - flex: 0 0 auto; - padding: unit(4); - - .icon { - height: 6rem; - width: 6rem; - } - } - - .progress-container { - align-self: center; - - .progress-bar { - width: 20rem; - height: 1.5rem; - background: var(--color-platinum); - margin-bottom: unit(1); - - &__filler { - background: var(--primary); - } - } - } - - &__content { - .scrollable { - padding: unit(2) 0; - max-height: 15rem; - } - } -} - -.export-progress-footer { - display: flex; - justify-content: flex-end; -} - diff --git a/src/styles/containers/_setupScreen.scss b/src/styles/containers/_setupScreen.scss deleted file mode 100644 index 3676615325..0000000000 --- a/src/styles/containers/_setupScreen.scss +++ /dev/null @@ -1,254 +0,0 @@ -:root { - - --manage-interview-panel-height: 10rem; - --header-height: 25vh; - -} - -@media (max-height: 799px) { - :root { - --header-height: 20vh; - --manage-interview-panel-height: 10rem; - } -} - - -.bg { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-size: cover; - background-repeat: no-repeat; - height: 100%; - width: 100%; - background-position: center; - - &.bg-1 { - background-image: url('../images/main-background.png'); - } -} - -.setup { - height: 100%; - width: 100%; - z-index: var(--z-default); - position: absolute; - top: 0; - background-image: linear-gradient(to top, var(--background), transparent 10rem); - - .settings-menu-button { - position: absolute; - top: unit(4); - left: unit(4); - } - - &--show-sessions { - .setup__header { - opacity: 0; - transform: scale(0.2); - transition-delay: unset; - } - - .setup__main { - opacity: 0; - transition-delay: unset; - - .swiper-button-next { - transform: translateX(6rem); - transition-delay: unset; - } - - .swiper-button-prev { - transform: translateX(-6rem); - transition-delay: unset; - } - } - } - - &__header { - @include transition-properties((opacity transform), var(--animation-easing), var(--animation-duration-fast)); - align-items: center; - justify-content: center; - height: var(--header-height); - display: flex; - flex-direction: column; - transition-delay: var(--animation-duration-fast); - padding: 2rem; - - &--logo { - height: 100%; - max-height: 10rem; - } - } - - - - &__main { - @include transition-properties((opacity), var(--animation-easing), var(--animation-duration-fast)); - flex: 1 1 auto; - display: flex; - align-items: center; - height: calc(100% - var(--header-height) * 2); - transition-delay: var(--animation-duration-fast); - - .scrollable { - height: 100%; // TODO: should this be on component? - } - - .swiper-wrapper { - box-sizing: border-box; - } - - .swiper-slide { - display: flex; - align-items: center; - justify-content: center; - align-self: flex-start; - width: 50vw; - padding: unit(2); - - &:not(.swiper-slide-active) { - pointer-events: none; - } - } - - &.swiper-container { - width: 100%; // ensure Swiper plugin initializes layout correctly - } - - .swiper-button-prev { - left: 0; - } - - .swiper-button-next { - right: 0; - } - - .icon { - &.swiper-button-next, - &.swiper-button-prev { - @include transition-properties((transform), var(--animation-easing), var(--animation-duration-fast)); - width: 6rem; - margin-top: -3rem; - height: auto; - background: none; - transition-delay: var(--animation-duration-fast); - - .cls-2 { - fill: var(--color-cyber-grape); - } - } - } - - .swiper-container-horizontal>.swiper-pagination-bullets { - .swiper-pagination-bullet { - margin: 0 .35rem; - } - } - - .swiper-pagination-bullet { - width: .75rem; - height: .75rem; - border: .07rem solid var(--color-white); - background: transparent; - opacity: 1; - transition: background var(--animation-duration-fast) var(--animation-easing); - } - - .swiper-pagination-bullet-active { - background: var(--color-platinum); - } - - } - - &__server-button { - @include floating-action-button; - width: 10rem; - height: 10rem; - } - - &__project-logo { - position: absolute; - bottom: 1.8rem; - left: 1.8rem; - width: 10rem; - height: 10rem; - z-index: var(--z-default); - } - -} - -.import-protocol-overlay { - - .import-protocol-overlay__content { - display: flex; - flex-direction: column; - align-items: center; - - h4 { - margin-top: 1rem; - } - - .icon { - height: 8rem; - background: var(--color-platinum); - border-radius: 50%; - } - - .progress-bar { - width: 20rem; - height: 1.5rem; - background: var(--color-platinum); - margin-bottom: unit(1); - - &__filler { - background: var(--primary); - } - } - } -} - -.resume-session-panel { - transition: transform var(--animation-duration-slow) var(--animation-easing); - width: 100%; - height: 100%; - position: absolute; - top: calc(100% - var(--manage-interview-panel-height)); - z-index: var(--z-panel); - - &--toggle { - height: var(--manage-interview-panel-height); - text-align: center; - display: flex; - align-items: center; - justify-content: center; - - .toggle-button { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - - h4 { - margin-bottom: 0; - } - - .toggle-image { - height: 2rem; - transition: transform var(--animation-duration-fast) var(--animation-easing); - } - } - } - - &--open { - transform: translateY(calc(-100% + var(--manage-interview-panel-height))); - - .toggle-image { - transform: rotate(180deg); - } - - } - -} From 3a815d9c938fbc3be482809b49f7a64e863ec032 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 31 Jul 2020 15:54:39 +0100 Subject: [PATCH 02/71] add diagnostic reports to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 51cb852f31..8a73b0875a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ IDEWorkspaceChecks.plist # Ignore protocol template used for schema generation src/schemas/abstract-protocol.json + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json From 479e48f9920ef90b9b2b822bc027db87ec6eb082 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 31 Jul 2020 15:56:07 +0100 Subject: [PATCH 03/71] make DragPreview portal to for better z-index management --- src/behaviours/DragAndDrop/DragPreview.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/behaviours/DragAndDrop/DragPreview.js b/src/behaviours/DragAndDrop/DragPreview.js index 8664077ed7..ebb40946a0 100644 --- a/src/behaviours/DragAndDrop/DragPreview.js +++ b/src/behaviours/DragAndDrop/DragPreview.js @@ -11,17 +11,18 @@ const getSize = (element) => { }; }; -const styles = (x, y) => - `display: inline-block; position: absolute; left: 0px; top: 0px; transform: translate(${x}px, ${y}px);`; +const styles = (width, height, x, y) => + `width: ${width}px; height: ${height}px; display: inline-block; position: absolute; left: 0px; top: 0px; transform: translate(${x}px, ${y}px);`; -const parent = () => document.getElementById('page-wrap'); +const body = () => document.getElementsByTagName('body')[0]; export default class DraggablePreview { constructor(node) { this.node = document.createElement('div'); + this.initialSize = getSize(node); this.node.setAttribute('class', 'draggable-preview'); - this.x = -1000; - this.y = -1000; + // this.x = -1000; + // this.y = -1000; this.validMove = true; this.update(); @@ -30,7 +31,7 @@ export default class DraggablePreview { this.node.appendChild(clone); - parent().appendChild(this.node); + body().appendChild(this.node); } size() { @@ -55,7 +56,7 @@ export default class DraggablePreview { } render() { - this.node.setAttribute('style', styles(this.x, this.y)); + this.node.setAttribute('style', styles(this.initialSize.width, this.initialSize.height, this.x, this.y)); if (this.validMove) { this.node.setAttribute('class', 'draggable-preview'); @@ -75,6 +76,6 @@ export default class DraggablePreview { cleanup() { window.cancelAnimationFrame(this.animationFrame); - parent().removeChild(this.node); + body().removeChild(this.node); } } From 8c93c1583daed346506f74d4c9a2d112963347d9 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 31 Jul 2020 15:57:16 +0100 Subject: [PATCH 04/71] Fix CardList causing app crash when window height is greater than window width --- src/components/CardList.js | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/CardList.js b/src/components/CardList.js index fd4fc09a85..a1265f2ca3 100644 --- a/src/components/CardList.js +++ b/src/components/CardList.js @@ -8,15 +8,22 @@ import { selectable } from '../behaviours'; import { Card } from '.'; import { entityPrimaryKeyProperty } from '../ducks/modules/network'; -/* eslint-disable */ -// [ ratio, columns ] -// First match (windowRatio > ratio) sets columns -const ratios = [ - [ 16/ 9, 4 ], - [ 16/10, 3 ], - [ 4/ 3, 2 ], -]; -/* eslint-enable */ + +const calculateRequiredColumns = (width, height) => { + // Tuple in format of [ratio, noOfColumns] + const ratios = [ + [16 / 9, 4], + [16 / 10, 3], + [4 / 3, 2], + ]; + + const windowRatio = width / height; + + // Calculate appropriate col number by finding closest ratio, or defaulting to 1 + const [, columns] = ratios.find(([ratio]) => windowRatio > ratio) || [0, 1]; + + return columns; +}; const EnhancedCard = selectable(Card); @@ -37,11 +44,7 @@ class CardList extends Component { } componentWillMount() { - const windowRatio = window.innerWidth / window.innerHeight; - - const [, columns] = ratios.find(([ratio]) => windowRatio > ratio); - - this.columns = columns; + this.columns = calculateRequiredColumns(window.innerWidth, window.innerHeight); } getColumns = () => From 75c70829774b25eabb007b47480cc701cc1dd174 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 31 Jul 2020 16:00:00 +0100 Subject: [PATCH 05/71] Add ProtocoLCard component --- src/components/ProtocolCard.js | 174 +++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/components/ProtocolCard.js diff --git a/src/components/ProtocolCard.js b/src/components/ProtocolCard.js new file mode 100644 index 0000000000..cc6d691b18 --- /dev/null +++ b/src/components/ProtocolCard.js @@ -0,0 +1,174 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { connect } from 'react-redux'; +import { Icon } from '@codaco/ui'; +import { Scroller } from '.'; +import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; +import { APP_SUPPORTED_SCHEMA_VERSIONS, APP_SCHEMA_VERSION } from '../config'; + +const formatDate = timestamp => timestamp && new Date(timestamp).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); + +const ProtocolCard = (props) => { + const { + attributes, + protocolUID, + onClickHandler, + openDialog, + } = props; + + const { + schemaVersion, + lastModified, + installationDate, + name, + description, + } = attributes; + + const isOutdatedProtocol = () => + schemaVersion !== APP_SCHEMA_VERSION && + APP_SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion); + + const isObsoleteProtocol = () => false; // To be implemented in future, as needed. + + const handleSchemaOutdatedInfo = () => { + openDialog({ + type: 'Notice', + title: 'Schema can be updated', + canCancel: false, + message: ( + +

    + This protocol uses an older version of the protocol file format, or "schema". +

    +

    + Newer schema versions support additional features in Network Canvas. During the beta + phase, we kindly request that you update your protocols to the latest version, and + evaluate the newest features as we implement them. To do this, open the original + protocol file it in the latest version of Architect, and follow the migration + instructions. Once migrated, install the new version of the protocol on this device. +

    +

    + For documentation on this issue, please see our documentation site. +

    +

    + In the meantime, you can continue to use this protocol to start interviews or + export data. +

    +
    + ), + }); + }; + + const handleSchemaObsoleteInfo = () => { + openDialog({ + type: 'Error', + title: 'Obsolete Protocol Schema', + canCancel: false, + message: ( + +

    + This protocol uses an obsolete version of the protocol file format, or + "schema". +

    +

    + The version of the schema used by this protocol is incompatible with this version of + Network Canvas. You may still export interview data that has already been collected, + but you may not start additional interviews. +

    +

    + If you require the ability to start interviews, you can either (1) install an updated + version of this protocol that uses the latest schema, or (2) downgrade your version + of Network Canvas to a version that supports this protocol schema version. +

    +

    + For documentation on this issue, please see our documentation site. +

    +
    + ), + }); + }; + + const modifierClasses = cx( + 'protocol-card', + { 'protocol-card--info': !isObsoleteProtocol() && isOutdatedProtocol() }, + { 'protocol-card--error': isObsoleteProtocol() }, + ); + + const renderCardIcon = () => { + if (isOutdatedProtocol()) { + return ( +
    { e.stopPropagation(); handleSchemaOutdatedInfo(); }}> + +
    + ); + } + + if (isObsoleteProtocol()) { + return ( +
    { e.stopPropagation(); handleSchemaObsoleteInfo(); }}> + +
    + ); + } + + return (''); + }; + + return ( +
    onClickHandler(protocolUID)}> +
    +
    + +
    + {renderCardIcon()} +
    +
    Installed: {formatDate(installationDate)}
    +
    Last Modified: {formatDate(lastModified)}
    +
    Schema Version: {schemaVersion}
    +
    +
    +
    +

    {name}

    + + { description || (No protocol description.) } + +
    +
    + ); +}; + +ProtocolCard.defaultProps = { + className: '', + onClickHandler: () => {}, + description: null, +}; + +ProtocolCard.propTypes = { + onClickHandler: PropTypes.func, + openDialog: PropTypes.func.isRequired, + isSelected: PropTypes.bool.isRequired, + attributes: PropTypes.shape({ + schemaVersion: PropTypes.number.isRequired, + lastModified: PropTypes.string.isRequired, + installationDate: PropTypes.string, + name: PropTypes.string.isRequired, + description: PropTypes.string, + }).isRequired, + protocolUID: PropTypes.string.isRequired, + +}; + +function mapStateToProps(state) { + return { + isSelected: uuid => state.selectedSessions && state.selectedSessions.includes(uuid), + installedProtocols: state.installedProtocols, + }; +} + +const mapDispatchToProps = { + openDialog: dialogActions.openDialog, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ProtocolCard); + From b5219aeb96d816abd60facf4e01d61ad74d015e2 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 3 Aug 2020 11:53:53 +0100 Subject: [PATCH 06/71] upgrade framer-motion --- package-lock.json | 28 ++++++--------------- package.json | 2 +- src/components/SessionPanel/SessionPanel.js | 3 ++- src/components/StagesMenu/StagesMenu.js | 9 +------ 4 files changed, 11 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2568da5f8e..54352b277a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17761,9 +17761,9 @@ } }, "framer-motion": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-1.10.3.tgz", - "integrity": "sha512-VooCzGWg7brSO4Gc0YwpY5AadJe4OPS74ZyOlOHWll5rMXCoOc6Ia3uDQ6RfOJlwCP/D9TQuRGtboyJiVXjVcw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.3.0.tgz", + "integrity": "sha512-zX6V5vz3joMzacqV7UpiHKUtqLMmU/YsVM6KpeRCi65KjUiymUX5O2jkpR3cCdlr1DkJ1yWUjBWY7xyiO834VA==", "dev": true, "requires": { "@emotion/is-prop-valid": "^0.8.2", @@ -17772,8 +17772,7 @@ "framesync": "^4.0.4", "hey-listen": "^1.0.8", "popmotion": "9.0.0-beta-8", - "style-value-types": "^3.1.6", - "stylefire": "^7.0.2", + "style-value-types": "^3.1.9", "tslib": "^1.10.0" } }, @@ -28791,25 +28790,12 @@ } }, "style-value-types": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-3.1.7.tgz", - "integrity": "sha512-jPaG5HcAPs3vetSwOJozrBXxuHo9tjZVnbRyBjxqb00c2saIoeuBJc1/2MtvB8eRZy41u/BBDH0CpfzWixftKg==", - "dev": true, - "requires": { - "hey-listen": "^1.0.8", - "tslib": "^1.10.0" - } - }, - "stylefire": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/stylefire/-/stylefire-7.0.3.tgz", - "integrity": "sha512-Q0l7NSeFz/OkX+o6/7Zg3VZxSAZeQzQpYomWmIpOehFM/rJNMSLVX5fgg6Q48ut2ETNKwdhm97mPNU643EBCoQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-3.1.9.tgz", + "integrity": "sha512-050uqgB7WdvtgacoQKm+4EgKzJExVq0sieKBQQtJiU3Muh6MYcCp4T3M8+dfl6VOF2LR0NNwXBP1QYEed8DfIw==", "dev": true, "requires": { - "@popmotion/popcorn": "^0.4.4", - "framesync": "^4.0.0", "hey-listen": "^1.0.8", - "style-value-types": "^3.1.7", "tslib": "^1.10.0" } }, diff --git a/package.json b/package.json index 62f24ac387..1ce4802b5e 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "eventemitter3": "^3.1.0", "faker": "^4.1.0", "file-loader": "1.1.11", - "framer-motion": "^1.10.3", + "framer-motion": "^2.3.0", "fs-extra": "3.0.1", "fuse.js": "^3.4.5", "history": "^4.7.2", diff --git a/src/components/SessionPanel/SessionPanel.js b/src/components/SessionPanel/SessionPanel.js index 39c5e27120..63b3f3ca31 100644 --- a/src/components/SessionPanel/SessionPanel.js +++ b/src/components/SessionPanel/SessionPanel.js @@ -54,7 +54,8 @@ const SessionPanel = React.forwardRef((props, ref) => { { } }; - const positionTransition = { - type: 'spring', - damping: 200, - stiffness: 1200, - velocity: 200, - }; - const onFilterChange = event => setFilter(event.target.value || ''); const filteredStageList = stages.filter( @@ -140,7 +133,7 @@ const StagesMenu = (props) => { animate="expanded" exit="filtered" key={item.id} - positionTransition={positionTransition} + layout className="stages-menu__preview-wrapper" > Date: Wed, 5 Aug 2020 22:18:49 +0100 Subject: [PATCH 07/71] welcome and interview panel --- package-lock.json | 47 +++- package.json | 2 +- public/index.html | 158 +++++++++++ src/components/ProtocolCard.js | 79 ++---- src/components/Scroller.js | 12 +- src/components/index.js | 1 + src/containers/App.js | 45 +++- src/containers/StartScreen/StartScreen.js | 263 +++++++++++++++++++ src/containers/StartScreen/Switch.js | 61 +++++ src/containers/StartScreen/index.js | 2 + src/images/NC-Round.svg | 2 +- src/images/main-background.png | Bin 279354 -> 67553 bytes src/images/project-logo-beta.svg | 1 - src/routes.js | 10 +- src/styles/components/_all.scss | 1 - src/styles/components/_card-list.scss | 2 +- src/styles/components/_categorical-item.scss | 2 +- src/styles/components/_node-list.scss | 2 +- src/styles/components/_ordinal-bins.scss | 2 +- src/styles/components/_scrollable.scss | 53 ---- src/styles/containers/StartScreen/_all.scss | 256 ++++++++++++++++++ src/styles/containers/_all.scss | 1 + src/styles/containers/_app.scss | 1 + src/styles/main.scss | 3 - 24 files changed, 853 insertions(+), 153 deletions(-) create mode 100644 src/containers/StartScreen/StartScreen.js create mode 100644 src/containers/StartScreen/Switch.js create mode 100644 src/containers/StartScreen/index.js delete mode 100644 src/images/project-logo-beta.svg delete mode 100644 src/styles/components/_scrollable.scss create mode 100644 src/styles/containers/StartScreen/_all.scss diff --git a/package-lock.json b/package-lock.json index 54352b277a..6c3566390b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4993,6 +4993,12 @@ "tslib": "^1.10.0" } }, + "@scarf/scarf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.1.0.tgz", + "integrity": "sha512-b2iE8kjjzzUo2WZ0xuE2N77kfnTds7ClrDxcz3Atz7h2XrNVoAPUoT75i7CY0st5x++70V91Y+c6RpBX9MX7Jg==", + "dev": true + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -26325,19 +26331,46 @@ } }, "redux-form": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/redux-form/-/redux-form-7.4.2.tgz", - "integrity": "sha512-QxC36s4Lelx5Cr8dbpxqvl23dwYOydeAX8c6YPmgkz/Dhj053C16S2qoyZN6LO6HJ2oUF00rKsAyE94GwOUhFA==", + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/redux-form/-/redux-form-8.3.6.tgz", + "integrity": "sha512-PfDdi+9JtNdr29Pv+9123TiojJlFB6+qczCRfP++cVT4nO2tEv4tDJHSV1l/Ol3Qh9z2cRg1QeLMhShNtRZ3+g==", "dev": true, "requires": { + "@babel/runtime": "^7.9.2", + "@scarf/scarf": "^1.0.5", "es6-error": "^4.1.1", - "hoist-non-react-statics": "^2.5.4", + "hoist-non-react-statics": "^3.3.2", "invariant": "^2.2.4", "is-promise": "^2.1.0", - "lodash": "^4.17.10", - "lodash-es": "^4.17.10", + "lodash": "^4.17.15", "prop-types": "^15.6.1", - "react-lifecycles-compat": "^3.0.4" + "react-is": "^16.4.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.0.tgz", + "integrity": "sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "requires": { + "react-is": "^16.7.0" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } } }, "redux-logger": { diff --git a/package.json b/package.json index 1ce4802b5e..74b4bdcd7a 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "react-virtualized": "^9.21.1", "recompose": "^0.26.0", "redux": "^3.6.0", - "redux-form": "^7.4.2", + "redux-form": "^8.3.6", "redux-logger": "^2.7.4", "redux-mock-store": "^1.5.3", "redux-observable": "^0.14.1", diff --git a/public/index.html b/public/index.html index e012f7c994..db6b12e805 100644 --- a/public/index.html +++ b/public/index.html @@ -17,8 +17,166 @@ /> Network Canvas + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/src/components/ProtocolCard.js b/src/components/ProtocolCard.js index cc6d691b18..45722c8abf 100644 --- a/src/components/ProtocolCard.js +++ b/src/components/ProtocolCard.js @@ -1,20 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import cx from 'classnames'; import { connect } from 'react-redux'; -import { Icon } from '@codaco/ui'; -import { Scroller } from '.'; +import { ProtocolCard as UIProtocolCard } from '@codaco/ui/lib/components/Cards'; import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; import { APP_SUPPORTED_SCHEMA_VERSIONS, APP_SCHEMA_VERSION } from '../config'; -const formatDate = timestamp => timestamp && new Date(timestamp).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' }); - const ProtocolCard = (props) => { const { attributes, - protocolUID, onClickHandler, openDialog, + condensed, } = props; const { @@ -89,65 +85,42 @@ const ProtocolCard = (props) => { }); }; - const modifierClasses = cx( - 'protocol-card', - { 'protocol-card--info': !isObsoleteProtocol() && isOutdatedProtocol() }, - { 'protocol-card--error': isObsoleteProtocol() }, - ); - - const renderCardIcon = () => { - if (isOutdatedProtocol()) { - return ( -
    { e.stopPropagation(); handleSchemaOutdatedInfo(); }}> - -
    - ); - } - + const handleStatusClick = () => { if (isObsoleteProtocol()) { - return ( -
    { e.stopPropagation(); handleSchemaObsoleteInfo(); }}> - -
    - ); + handleSchemaObsoleteInfo(); + return; } - return (''); + handleSchemaOutdatedInfo(); }; + console.log('protocolcard', props); return ( -
    onClickHandler(protocolUID)}> -
    -
    - -
    - {renderCardIcon()} -
    -
    Installed: {formatDate(installationDate)}
    -
    Last Modified: {formatDate(lastModified)}
    -
    Schema Version: {schemaVersion}
    -
    -
    -
    -

    {name}

    - - { description || (No protocol description.) } - -
    -
    + handleStatusClick} + onClickHandler={onClickHandler} + /> ); }; ProtocolCard.defaultProps = { - className: '', onClickHandler: () => {}, description: null, + condensed: false, }; ProtocolCard.propTypes = { onClickHandler: PropTypes.func, openDialog: PropTypes.func.isRequired, - isSelected: PropTypes.bool.isRequired, + condensed: PropTypes.bool, attributes: PropTypes.shape({ schemaVersion: PropTypes.number.isRequired, lastModified: PropTypes.string.isRequired, @@ -155,20 +128,12 @@ ProtocolCard.propTypes = { name: PropTypes.string.isRequired, description: PropTypes.string, }).isRequired, - protocolUID: PropTypes.string.isRequired, }; -function mapStateToProps(state) { - return { - isSelected: uuid => state.selectedSessions && state.selectedSessions.includes(uuid), - installedProtocols: state.installedProtocols, - }; -} - const mapDispatchToProps = { openDialog: dialogActions.openDialog, }; -export default connect(mapStateToProps, mapDispatchToProps)(ProtocolCard); +export default connect(null, mapDispatchToProps)(ProtocolCard); diff --git a/src/components/Scroller.js b/src/components/Scroller.js index c6f40f9561..89baa3fd4f 100644 --- a/src/components/Scroller.js +++ b/src/components/Scroller.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; +import { Scroller as UIScroller } from '@codaco/ui/lib/components'; import { connect } from 'react-redux'; -import cx from 'classnames'; import PropTypes from 'prop-types'; class Scroller extends Component { @@ -29,14 +29,14 @@ class Scroller extends Component { } = this.props; return ( -
    {children} -
    + ); } } diff --git a/src/components/index.js b/src/components/index.js index 2212502abd..ccd3b6b09e 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,4 +1,5 @@ export { default as Card } from './Card'; +export { default as ProtocolCard } from './ProtocolCard'; export { default as SessionCard } from './SessionCard'; export { default as CardList } from './CardList'; export { default as FilterableListWrapper } from './FilterableListWrapper'; diff --git a/src/containers/App.js b/src/containers/App.js index ab306fe617..2cf9b695e1 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -1,8 +1,10 @@ import React, { PureComponent } from 'react'; +import { motion } from 'framer-motion'; import PropTypes from 'prop-types'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router'; +import { getCSSVariableAsNumber } from '@codaco/ui/lib/utils/CSSVariables'; import cx from 'classnames'; import 'swiper/css/swiper.css'; import { actionCreators as deviceSettingsActions } from '../ducks/modules/deviceSettings'; @@ -66,19 +68,36 @@ class App extends PureComponent { render() { const { children } = this.props; + + const list = { + visible: { + opacity: 1, + transition: { + when: 'beforeChildren', + }, + }, + hidden: { + opacity: 0, + }, + }; + return ( -
    -
    + ); } } diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js new file mode 100644 index 0000000000..c49bc2982e --- /dev/null +++ b/src/containers/StartScreen/StartScreen.js @@ -0,0 +1,263 @@ +import React from 'react'; +import { motion, AnimateSharedLayout } from 'framer-motion'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Button, GraphicButton } from '@codaco/ui'; +import NCLogo from '../../images/NC-Round.svg'; +import { ProtocolCard, Scroller } from '../../components'; +import Switch from './Switch'; + + +const ActionButton = props => { + return ( +
    + { props.children } +
    + ); +}; + +const StartScreen = (props) => { + + const springy = { + visible: { + opacity: 1, + scale: 1, + transition: { + type: 'spring', + }, + }, + hidden: { + opacity: 0, + scale: 0.5, + transition: { + type: 'spring', + }, + }, + }; + + const opacity = { + visible: { + opacity: 1, + transition: { + when: 'beforeChildren', + staggerChildren: 0.15, + }, + }, + hidden: { + opacity: 0, + }, + }; + + return ( +
    + + + +
    +
    +

    Network Canvas

    +

    Simplifying complex network data collection.

    +
    +
    + Network Canvas +
    +
    +
    5.2.0
    +
    + +
    +
    +

    Welcome to Network Canvas!

    +

    + Thank you for taking the time to explore our software. For feedback and + support, please visit networkcanvas.com. +

    +
    +
    + +
    +
    +

    + If this is your first + time using Network Canvas, please consider taking a moment to watch + the overview video below. It will introduce you to the key concepts + of the Network Canvas project. +

    +
    +
    +
    +
    + +
    +
    +

    + For further detailed information, tutorials, videos, and information about + collaboration, please visit our documentation website. +

    +
    +
    +
    +
    + +
    +
    +

    + To get started right away, install one or more interview protocols + onto this device. For convenience, we have created a sample interview + protocol on the theme of "public health" research. +

    +
    +
    +
    +
    +
    + console.log('clicked')} + /> +
    +
    + +
    +
    +
    +
    +

    Start an Interview

    +
    + +
    +
    +

    Other protocols...

    + + + + + + + +
    +
    +
    +
    +
    +

    Install New Protocol

    +
    +
    + +

    Import

    +

    From URL

    +
    + +

    Import

    +

    From File

    +
    + +

    Import

    +

    From Server

    +
    +
    +
    +
    + +
    +
    +

    Interview Sessions

    +
    +
    +
    + +
    +
    +

    Server

    +
    +
    +
    +
    +
    +
    + ); +}; + +StartScreen.defaultProps = { +}; + +StartScreen.propTypes = { + +}; + +const mapDispatchToProps = { + +}; + +const mapStateToProps = (state, ownProps) => { + return { + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(StartScreen); diff --git a/src/containers/StartScreen/Switch.js b/src/containers/StartScreen/Switch.js new file mode 100644 index 0000000000..31a0d330fe --- /dev/null +++ b/src/containers/StartScreen/Switch.js @@ -0,0 +1,61 @@ +import React, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import uuid from 'uuid'; + +const Switch = ({ + label, + on, + disabled, + className, + onChange, +}) => { + const id = useRef(uuid()); + + // const [on, setIsOn] = useState(false); + + // const onChange = () => + // setIsOn(v => !v); + + const classes = cx( + 'switch', + className, + { 'switch--on': on }, + ); + + return ( +
    @@ -78,12 +78,12 @@ const ExportOptions = (props) => {

    Merge Sessions

    - If you enable this option, exporting multiple sessions at the same time will cause - them to be merged into a single file, on a per-protocol basis. In the case of CSV - export, you will receive one of each type of file for each protocol. In the case - of GraphML you will receive a single GraphML file with - multiple graph elements. Please note that most software does not yet - support multiple graphs in a single GraphML file. + If you enable this option, exporting multiple sessions at the same time will cause + them to be merged into a single file, on a per-protocol basis. In the case of CSV + export, you will receive one of each type of file for each protocol. In the case + of GraphML you will receive a single GraphML file with + multiple graph elements. Please note that most software does not yet + support multiple graphs in a single GraphML file.

    diff --git a/src/components/SettingsMenu/SettingsMenu.js b/src/components/SettingsMenu/SettingsMenu.js index 4b1e7c60ac..3908005486 100644 --- a/src/components/SettingsMenu/SettingsMenu.js +++ b/src/components/SettingsMenu/SettingsMenu.js @@ -31,6 +31,26 @@ const SettingsMenu = (props) => { About, }; + const baseVariants = { + show: { + opacity: 1, + transition: { + when: 'beforeChildren', + staggerChildren: 0.07, + duration: baseAnimationDuration, + easing: baseAnimationEasing, + }, + }, + hide: { + opacity: 0, + transition: { + when: 'afterChildren', + duration: baseAnimationDuration, + easing: baseAnimationEasing, + }, + }, + }; + const variants = { show: { x: '0%', @@ -150,11 +170,15 @@ const SettingsMenu = (props) => { { settingsMenuOpen && ( -
    + @@ -171,7 +195,7 @@ const SettingsMenu = (props) => { { renderTabs } {/* */} -
    +
    )} diff --git a/src/containers/Server/CharInput.js b/src/containers/Server/CharInput.js new file mode 100644 index 0000000000..73466df6f2 --- /dev/null +++ b/src/containers/Server/CharInput.js @@ -0,0 +1,58 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +// TODO: Share with Server +// eslint-disable-next-line @codaco/spellcheck/spell-checker +const charSet = 'abcdefghijklmnopqrstuvwxyz'.split(''); + +const CharInput = ({ value, disabled, index, onChange }) => { + let className = 'pairing-code-input__character'; + if (value && !charSet.includes(value)) { + className += ' pairing-code-input__character--error'; + } + return ( + + evt.target.select()} + type="text" + value={value} + /> + + ); +}; + +CharInput.focusNext = (currentInput) => { + const nextLabel = currentInput.nextElementSibling; + if (nextLabel && nextLabel.focus) { + nextLabel.focus(); + } +}; + +CharInput.focusPrevious = (currentInput) => { + const prevLabel = currentInput.previousElementSibling; + const prevInput = prevLabel && prevLabel.previousElementSibling; + if (prevInput && prevInput.focus) { + prevInput.focus(); + } +}; + +CharInput.defaultProps = { + disabled: false, +}; + +CharInput.propTypes = { + value: PropTypes.string.isRequired, + disabled: PropTypes.bool, + index: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default CharInput; diff --git a/src/containers/StartScreen/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js similarity index 94% rename from src/containers/StartScreen/DiscoveredServerList.js rename to src/containers/Server/DiscoveredServerList.js index 63e8e0f921..8d73e2088f 100644 --- a/src/containers/StartScreen/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -1,6 +1,4 @@ import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; import { AnimatePresence, motion } from 'framer-motion'; import { Icon, Spinner, Scroller } from '@codaco/ui'; import { ServerCard as UIServerCard } from '@codaco/ui/lib/components/Cards'; @@ -64,9 +62,9 @@ const DiscoveredServerList = () => { updateServerList(prevState => prevState.filter(item => (item.name !== response.name))); }); - serverDiscoverer.on('SERVER_ERROR', (error) => { - console.log('server_error', error); - setError(error); + serverDiscoverer.on('SERVER_ERROR', (serverError) => { + console.log('server_error', serverError); + setError(serverError); }); serverDiscoverer.init(); @@ -91,7 +89,7 @@ const DiscoveredServerList = () => {
    - ) + ); } console.log('serverlist', serverList); @@ -123,7 +121,7 @@ const DiscoveredServerList = () => { ); -} +}; DiscoveredServerList.defaultProps = { }; diff --git a/src/containers/Server/PairingCodeDialog.js b/src/containers/Server/PairingCodeDialog.js new file mode 100644 index 0000000000..5a8a4d4209 --- /dev/null +++ b/src/containers/Server/PairingCodeDialog.js @@ -0,0 +1,197 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { Button, Spinner } from '@codaco/ui'; +import { PairingCodeLength } from 'secure-comms-api/pairingCodeConfig'; +import ApiClient from '../../utils/ApiClient'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { actionCreators as pairedServerActions } from '../../ducks/modules/pairedServer'; +import { Overlay } from '../Overlay'; +import PairingCodeInput from './PairingCodeInput'; + +const initialState = { + loading: false, + pairingCode: null, + pairingRequestSalt: null, + pairingRequestId: null, +}; + +const PairingCodeDialog = (props) => { + const { + show, + server, + handleClose, + handleSuccess, + openDialog, + setPairedServer, + deviceName, + } = props; + + const [{ + loading, + pairingCode, + pairingRequestSalt, + pairingRequestId, + }, setState, + ] = useState(initialState); + + const [submittable, setSubmittable] = useState(true); + + let apiClient; + + const handleError = (error) => { + openDialog({ + type: 'Error', + error, + confirmLabel: 'Okay', + onConfirm: handleClose, + }); + }; + + const requestPairingCode = () => { + setState(prevState => ({ ...prevState, loading: true })); + apiClient.requestPairing() + .then((data) => { + if (!data) { + // we aborted the request during unmount + return; + } + + setState(prevState => ({ + ...prevState, + loading: false, + pairingRequestSalt: data.salt, + pairingRequestId: data.pairingRequestId, + })); + }) + .catch(err => handleError(err)); + }; + + // Pairing step 2: derive a secret, send (encrypted) to server + // At this point, we have a known connection to LAN server. 'loading' state would only distract. + const confirmPairing = () => { + apiClient.confirmPairing(pairingCode, pairingRequestId, pairingRequestSalt, deviceName) + .then((pairingInfo) => { + const device = pairingInfo.device; + const server = addSecureApiUrlToServer({ + ...props.server, + securePort: pairingInfo.securePort, + sslCertificate: pairingInfo.sslCertificate, + }); + setState({ ...initialState }); + setPairedServer(server, device.id, device.secret); + }) + .then(() => handleSuccess()) + .catch(err => handleError(err)); + }; + + const completePairing = (code) => { + setState(prevState => ({ + ...prevState, + pairingCode: code, + })); + + confirmPairing(); + }; + + useEffect(() => { + if (server) { + apiClient = new ApiClient(server.pairingServiceUrl); + requestPairingCode(); + } + + return () => { + if (apiClient) { + apiClient.cancelAll(); + } + }; + }, [server]); + + return ( + + { + evt.preventDefault(); + // if (submittable) { + // document.activeElement.blur(); // attempt to hide soft keyboard on tablet + // this.setState({ submittable: false }); + + // } + // completePairing(pairingCode); + }} + > +

    + You must pair this device with this Server before you can securely exchange data. + This is a one-off process that allows your devices to identify each other. +

    + { + loading && + + + Please acknowledge the pairing request within the Server app to continue. + +
    + +
    +
    + } + + { !loading && + + + Please type the code shown on the Server setup screen into the box below + +
    + + + +
    +
    + } + +
    + ); +}; + +PairingCodeDialog.propTypes = { + handleClose: PropTypes.func.isRequired, + handleSuccess: PropTypes.func.isRequired, +}; + +PairingCodeDialog.defaultProps = { + deviceName: '', + onComplete: () => {}, + onError: () => {}, +}; + +const mapStateToProps = state => ({ + // show: !!state.ui.showPairingCodeDialog, + deviceName: state.deviceSettings.description, +}); + +const mapDispatchToProps = dispatch => ({ + openDialog: bindActionCreators(dialogActions.openDialog, dispatch), + setPairedServer: bindActionCreators(pairedServerActions.setPairedServer, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PairingCodeDialog); diff --git a/src/containers/Server/PairingCodeInput.js b/src/containers/Server/PairingCodeInput.js new file mode 100644 index 0000000000..3e98aae69d --- /dev/null +++ b/src/containers/Server/PairingCodeInput.js @@ -0,0 +1,89 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import CharInput from './CharInput'; + +const onKeyUp = (evt) => { + if (evt.key === 'Backspace') { + const input = evt.currentTarget.querySelector('input:focus'); + CharInput.focusPrevious(input); + } +}; + +class PairingCodeInput extends Component { + constructor(props) { + super(props); + + this.emptyChars = new Array(props.charCount); + this.emptyChars.fill(''); + Object.freeze(this.emptyChars); + + this.state = { characters: this.emptyChars }; + } + + onChange = (evt) => { + const target = evt.currentTarget; + const index = parseInt(target.getAttribute('data-index'), 10); + let value = target.value; + let newCharacters; + + if (value.length === this.props.charCount) { + // Handle paste of entire code + newCharacters = value.split(''); + target.blur(); + } else { + // TODO: this takes the last form input, which may not be the last typed. + value = value[value.length - 1] || ''; + newCharacters = this.state.characters.slice(); + newCharacters.splice(index, 1, value); + + if (value) { + CharInput.focusNext(target); + } + } + this.updateCharacters(newCharacters); + } + + clearForm() { + this.updateCharacters(this.emptyChars); + } + + updateCharacters(newCharacters) { + this.setState({ characters: newCharacters }, () => ( + this.props.setPairingCode(newCharacters.join('')) + )); + } + + render() { + const { disabled } = this.props; + return ( +
    +
    + { + this.state.characters.map((char, i) => ( + + )) + } +
    +
    + ); + } +} + +PairingCodeInput.defaultProps = { + disabled: false, +}; + +PairingCodeInput.propTypes = { + charCount: PropTypes.number.isRequired, + disabled: PropTypes.bool, + setPairingCode: PropTypes.func.isRequired, +}; + +export default PairingCodeInput; diff --git a/src/containers/Server/ServerPairingOverlay.js b/src/containers/Server/ServerPairingOverlay.js new file mode 100644 index 0000000000..c86a77c0e7 --- /dev/null +++ b/src/containers/Server/ServerPairingOverlay.js @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import ApiClient from '../../utils/ApiClient'; +import { addSecureApiUrlToServer } from '../../utils/serverAddressing'; +import { actionCreators } from '../../ducks/modules/pairedServer'; + +const initialState = { + loading: false, + pairingCode: null, + pairingRequestSalt: null, + pairingRequestId: null, +}; + +/** + * This component is responsible for pairing with a server selected or entered by the user + */ +const ServerPairingOverlay = ({ + deviceName, + setPairedServer, + onComplete, + onCancel, +}) => { + const [{ + loading, + pairingCode, + pairingRequestSalt, + pairingRequestId, + }, setState, + ] = useState(initialState); + + let apiClient; + + const handleApiError = (err) => { + console.log('api error', err); + // this.setState(emptyState); + // this.props.onError(err); + // Dispatch error action? + }; + + const requestPairingCode = () => { + setState(prevState => ({ ...prevState, loading: true })); + apiClient.requestPairing() + .then((data) => { + if (!data) { + // we aborted the request during unmount + return; + } + + setState(prevState => ({ + ...prevState, + loading: false, + pairingRequestSalt: data.salt, + pairingRequestId: data.pairingRequestId, + })); + }) + .catch(err => handleApiError(err)); + }; + + // Pairing step 2: derive a secret, send (encrypted) to server + // At this point, we have a known connection to LAN server. 'loading' state would only distract. + const confirmPairing = () => { + apiClient.confirmPairing(pairingCode, pairingRequestId, pairingRequestSalt, deviceName) + .then((pairingInfo) => { + const device = pairingInfo.device; + const server = addSecureApiUrlToServer({ + ...props.server, + securePort: pairingInfo.securePort, + sslCertificate: pairingInfo.sslCertificate, + }); + setState({ ...initialState }); + setPairedServer(server, device.id, device.secret); + }) + .then(() => onComplete()) + .catch(err => handleApiError(err)); + }; + + const completePairing = (code) => { + setState(prevState => ({ + ...prevState, + pairingCode: code, + })); + + confirmPairing(); + }; + + useEffect(() => { + apiClient = new ApiClient(this.props.server.pairingServiceUrl); + requestPairingCode(); + + return () => { + apiClient.cancelAll(); + }; + }, []); + + return ( + + { + + } + + ) +} + + + + +ServerPairingOverlay.defaultProps = { + deviceName: '', + onComplete: () => {}, + onError: () => {}, +}; + +ServerPairingOverlay.propTypes = { + deviceName: PropTypes.string, + onComplete: PropTypes.func, + onError: PropTypes.func, + server: PropTypes.shape({ + pairingServiceUrl: PropTypes.string.isRequired, + host: PropTypes.string, + }).isRequired, + setPairedServer: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +}; + +function mapStateToProps(state) { + return { + deviceName: state.deviceSettings.description, + }; +} + +function mapDispatchToProps(dispatch) { + return { + setPairedServer: bindActionCreators(actionCreators.setPairedServer, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ServerPairingOverlay); + +export { ServerPairingOverlay as UnconnectedServerPairingOverlay }; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index 33a7e0f9bb..af632decaf 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -2,7 +2,6 @@ import React from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { connect } from 'react-redux'; import { Button } from '@codaco/ui'; -import { Checkbox } from '@codaco/ui/lib/components/Fields'; import { Section } from '.'; import NCLogo from '../../images/NC-Round.svg'; import { actionCreators as deviceSettingsActions } from '../../ducks/modules/deviceSettings'; @@ -79,9 +78,9 @@ const HeaderSection = (props) => { collaboration, please visit our documentation website.

    - To get started right away, install one or more interview protocols - onto this device. For convenience, we have created a sample interview - protocol on the theme of "public health" research. + To get started right away, install one or more interview protocols + onto this device. For convenience, we have created a sample interview + protocol on the theme of "public health" research.

    diff --git a/src/containers/StartScreen/ProtocolUrlForm.js b/src/containers/StartScreen/ProtocolUrlForm.js index aa9265f9ba..3cfe40c724 100644 --- a/src/containers/StartScreen/ProtocolUrlForm.js +++ b/src/containers/StartScreen/ProtocolUrlForm.js @@ -16,7 +16,7 @@ const ProtocolUrlForm = ({ handleClose, submittable, }) => { - const onClickImportRemoteProtocol = (fields) => { + const handleSubmit = (fields) => { if (fields) { importProtocolFromURI(fields.protocol_url).then(() => { handleClose(); @@ -44,31 +44,29 @@ const ProtocolUrlForm = ({ }; return ( - - +

    + Enter the full URL to a protocol file below, including http:// or https:// at the start. +

    +
    -

    - Enter the full URL to a protocol file below, including http:// or https:// at the start. -

    - -
    - -
    -
    -
    -
    +
    + +
    + + ); }; diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index 94155210ee..a6a14961c6 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -1,12 +1,47 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; +import { isValid } from 'redux-form'; +import { connect } from 'react-redux'; +import { Button } from '@codaco/ui'; import { Form } from '../../containers/'; -import { isValidAddress, isValidPort, maxPort, minPort } from '../../utils/serverAddressing'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { isValidAddress, isValidPort, maxPort, minPort, addPairingUrlToService } from '../../utils/serverAddressing'; +import { Overlay } from '../Overlay'; +import PairingCodeDialog from '../Server/PairingCodeDialog'; -const ServerAddressForm = (props) => { - const { - submitHandler, - } = props; +const FORM_NAME = 'server-address-form'; + +const ServerAddressForm = ({ + show, + handleClose, + submittable, + openDialog, +}) => { + const [showPairingCodeDialog, setShowPairingCodeDialog] = useState(false); + const [selectedServer, setSelectedServer] = useState(null); + + const handleSubmit = (values) => { + console.log('settingserver', values); + + const serverWithPairingUrl = addPairingUrlToService({ + addresses: [values.serverAddress], + port: values.serverPort, + }); + + setSelectedServer(serverWithPairingUrl); + + if (serverWithPairingUrl.pairingServiceUrl) { + console.log('serverWithPairingURL', serverWithPairingUrl); + // Now show the pairing key + setShowPairingCodeDialog(true); + } else { + openDialog({ + type: 'Error', + error: 'Pairing request failed. An error occurred while attempting to send the pairing request.', + confirmLabel: 'Okay', + }); + } + }; const validateAddress = (address) => { if (!address) { @@ -36,43 +71,85 @@ const ServerAddressForm = (props) => { return undefined; }; + const formConfig = { + formName: FORM_NAME, + fields: [ + { + label: 'Server Address', + name: 'serverAddress', + component: 'Text', + placeholder: 'Enter an IP address or domain name...', + validate: [validateAddress], + }, + { + label: 'Server Port', + name: 'serverPort', + component: 'Number', + placeholder: 61001, + validate: [validatePort], + }, + ], + }; + + const initialValues = { + serverPort: 61001, + }; + return ( -
    -

    Enter manual connection information

    -
    + +

    + Enter the hostname or IP address of the computer running Server, and then + click send pairing request. The computer you want to request + pairing with must be reachable from this device using the details you provide. +

    + +
    + +
    + +
    + setShowPairingCodeDialog(false)} + handleSuccess={() => { setShowPairingCodeDialog(false); handleClose(); }} /> -
    + ); }; ServerAddressForm.defaultProps = { + show: false, }; ServerAddressForm.propTypes = { - submitHandler: PropTypes.func.isRequired, + handleClose: PropTypes.func.isRequired, + show: PropTypes.bool, +}; + +const mapDispatchToProps = { + openDialog: dialogActions.openDialog, }; -export default ServerAddressForm; +const mapStateToProps = state => ({ + submittable: isValid(FORM_NAME)(state), +}); + +export { ServerAddressForm }; + +export default connect(mapStateToProps, mapDispatchToProps)(ServerAddressForm); + diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index bf7a5d340d..f1288758b8 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -1,17 +1,20 @@ import React from 'react'; import { connect } from 'react-redux'; -import { AnimatePresence } from 'framer-motion'; -import { GraphicButton, Button } from '@codaco/ui'; -import { Section, DiscoveredServerList } from '.'; -import { Scroller } from '../../components'; -import { ProtocolCard } from '../../components/Cards'; +import { Button } from '@codaco/ui'; +import { actionCreators as uiActions } from '../../ducks/modules/ui'; +import { Section } from '.'; +import DiscoveredServerList from '../Server/DiscoveredServerList'; +import ServerAddressForm from './ServerAddressForm'; const ServerSection = (props) => { - // const { - // } = props; + const { + showServerAddressForm, + toggleShowServerAddressForm, + } = props; return (
    +
    @@ -21,7 +24,7 @@ const ServerSection = (props) => {
    - +
    @@ -40,11 +43,13 @@ ServerSection.defaultProps = { function mapStateToProps(state) { return { pairedServer: state.pairedServer, + showServerAddressForm: state.ui.showServerAddressForm, }; } function mapDispatchToProps(dispatch) { return { + toggleShowServerAddressForm: () => dispatch(uiActions.toggle('showServerAddressForm')), }; } diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index c3bfe4d826..e416995d5b 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -1,19 +1,15 @@ import React from 'react'; import { motion, AnimateSharedLayout } from 'framer-motion'; -import PropTypes from 'prop-types'; import { connect } from 'react-redux'; - import { ResumeSessionSection, HeaderSection, InterviewSection, ServerSection, - // ProtocolsOverlay, - // SessionsOverlay, } from '.'; -const StartScreen = (props) => { +const StartScreen = () => { const opacity = { visible: { opacity: 1, @@ -57,9 +53,7 @@ const mapDispatchToProps = { }; -const mapStateToProps = (state, ownProps) => { - return { - }; -}; +const mapStateToProps = () => ({ +}); export default connect(mapStateToProps, mapDispatchToProps)(StartScreen); diff --git a/src/containers/StartScreen/index.js b/src/containers/StartScreen/index.js index fa7db2515a..72e0f6ef15 100644 --- a/src/containers/StartScreen/index.js +++ b/src/containers/StartScreen/index.js @@ -5,4 +5,3 @@ export { default as InterviewSection } from './InterviewSection'; export { default as HeaderSection } from './HeaderSection'; export { default as ServerSection } from './ServerSection'; export { default as Switch } from './Switch'; -export { default as DiscoveredServerList } from './DiscoveredServerList'; diff --git a/src/ducks/modules/importProtocol.js b/src/ducks/modules/importProtocol.js index e18eb5da8a..1694da741d 100644 --- a/src/ducks/modules/importProtocol.js +++ b/src/ducks/modules/importProtocol.js @@ -175,7 +175,7 @@ const importProtocolFromURI = (uri, usePairedServer) => (dispatch, getState) => .then((protocolContent) => { if (getState().importProtocol.step === 0) return cancelledImport(); dispatch(importProtocolCompleteAction(protocolContent)); - resolve(); + return resolve(); }, catchError) .catch( (error) => { diff --git a/src/styles/components/settings-menu/_settings-menu.scss b/src/styles/components/settings-menu/_settings-menu.scss index 7c0526fb88..624ca4209e 100644 --- a/src/styles/components/settings-menu/_settings-menu.scss +++ b/src/styles/components/settings-menu/_settings-menu.scss @@ -10,6 +10,7 @@ display: flex; height: 100%; overflow: hidden; + background: var(--transparent-dark); nav { background: var(--panel-bg-muted); diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 6b24728a89..d35a02618f 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -21,7 +21,7 @@ const ProgressMessages = { statusText: 'Export finished.', }, UnexpectedResponseMessage: 'Unexpected Response', - NoResponseMessage: 'Server could not be reached', + NoResponseMessage: 'Server could not be reached at the address you provided. Check your networking settings on this device, and on the computer running Server and try again. Consult our documentation on pairing for detailed information on this topic.', }; const ApiErrorStatus = 'error'; From f5b87c4981904e0d9d7083d407bf8bb421cd4a57 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 21 Aug 2020 13:07:03 +0100 Subject: [PATCH 25/71] implement StackButton component --- src/components/Cards/SessionCard.js | 3 + src/components/StackButton.js | 48 ++++ src/containers/Overlay.js | 4 +- src/containers/Server/PairingCodeDialog.js | 143 +++++----- src/containers/StartScreen/ImportSection.js | 87 ++++++ .../StartScreen/InterviewSection.js | 178 +++++++------ .../StartScreen/NewSessionOverlay.js | 86 ++++++ src/containers/StartScreen/ProtocolUrlForm.js | 2 +- .../StartScreen/ResumeSessionSection.js | 66 ----- .../StartScreen/ServerAddressForm.js | 139 +++++----- src/containers/StartScreen/ServerSection.js | 23 +- src/containers/StartScreen/StartScreen.js | 4 +- src/containers/StartScreen/index.js | 2 +- src/styles/components/_overlay.scss | 14 +- .../components/_pairing-code-input.scss | 23 +- .../settings-menu/_settings-menu.scss | 2 +- src/styles/containers/StartScreen/_all.scss | 247 ++++++++++++++++-- src/utils/network-exporters | 2 +- 18 files changed, 735 insertions(+), 338 deletions(-) create mode 100644 src/components/StackButton.js create mode 100644 src/containers/StartScreen/ImportSection.js create mode 100644 src/containers/StartScreen/NewSessionOverlay.js delete mode 100644 src/containers/StartScreen/ResumeSessionSection.js diff --git a/src/components/Cards/SessionCard.js b/src/components/Cards/SessionCard.js index 713d98e614..dcecc6c467 100644 --- a/src/components/Cards/SessionCard.js +++ b/src/components/Cards/SessionCard.js @@ -31,6 +31,8 @@ const SessionCard = (props) => { setSession(sessionUUID); }; + console.log('sessioncard', props); + return ( { + console.log('mmm', state, props); const session = state.sessions[props.sessionUUID]; const protocol = get(state.installedProtocols, [session.protocolUID]); diff --git a/src/components/StackButton.js b/src/components/StackButton.js new file mode 100644 index 0000000000..cd93fb1e61 --- /dev/null +++ b/src/components/StackButton.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +const MockCard = ({ modifierClass, cardColor, insetColor }) => ( +
    +
    +
    +); + +const StackOfCards = ({ children, ...rest }) => ( + + {children} + + + + +); + +const StackButton = ({ cardColor, insetColor, label, children }) => ( + +

    {label}

    + +
    {children}
    +
    +
    +); + +export default StackButton; diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index 87f315f7d4..f6061ace18 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -1,6 +1,7 @@ import React from 'react'; import cx from 'classnames'; import { connect } from 'react-redux'; +import { motion } from 'framer-motion'; import PropTypes from 'prop-types'; import { Modal } from '@codaco/ui'; import { CloseButton } from '../components'; @@ -44,7 +45,7 @@ Overlay.propTypes = { title: PropTypes.string, show: PropTypes.bool, children: PropTypes.any, - useFullScreenForms: PropTypes.bool.isRequired, + useFullScreenForms: PropTypes.bool, forceDisableFullScreen: PropTypes.bool, className: PropTypes.string, }; @@ -57,6 +58,7 @@ Overlay.defaultProps = { show: false, children: null, forceDisableFullScreen: false, + useFullScreenForms: false, }; export { diff --git a/src/containers/Server/PairingCodeDialog.js b/src/containers/Server/PairingCodeDialog.js index 5a8a4d4209..9b70a928b5 100644 --- a/src/containers/Server/PairingCodeDialog.js +++ b/src/containers/Server/PairingCodeDialog.js @@ -7,21 +7,21 @@ import { PairingCodeLength } from 'secure-comms-api/pairingCodeConfig'; import ApiClient from '../../utils/ApiClient'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { actionCreators as pairedServerActions } from '../../ducks/modules/pairedServer'; -import { Overlay } from '../Overlay'; import PairingCodeInput from './PairingCodeInput'; +import { addSecureApiUrlToServer } from '../../utils/serverAddressing'; const initialState = { loading: false, pairingCode: null, pairingRequestSalt: null, pairingRequestId: null, + submittable: false, }; const PairingCodeDialog = (props) => { const { - show, server, - handleClose, + handleCancel, handleSuccess, openDialog, setPairedServer, @@ -33,22 +33,29 @@ const PairingCodeDialog = (props) => { pairingCode, pairingRequestSalt, pairingRequestId, + submittable, }, setState, ] = useState(initialState); - const [submittable, setSubmittable] = useState(true); - - let apiClient; + const apiClient = new ApiClient(server.pairingServiceUrl); const handleError = (error) => { openDialog({ type: 'Error', error, confirmLabel: 'Okay', - onConfirm: handleClose, + onConfirm: handleCancel, }); }; + const setPairingCode = (currentCode) => { + setState(prevState => ({ + ...prevState, + pairingCode: currentCode, + submittable: currentCode.length === PairingCodeLength, + })); + }; + const requestPairingCode = () => { setState(prevState => ({ ...prevState, loading: true })); apiClient.requestPairing() @@ -74,13 +81,13 @@ const PairingCodeDialog = (props) => { apiClient.confirmPairing(pairingCode, pairingRequestId, pairingRequestSalt, deviceName) .then((pairingInfo) => { const device = pairingInfo.device; - const server = addSecureApiUrlToServer({ + const pairedServer = addSecureApiUrlToServer({ ...props.server, securePort: pairingInfo.securePort, sslCertificate: pairingInfo.sslCertificate, }); setState({ ...initialState }); - setPairedServer(server, device.id, device.secret); + setPairedServer(pairedServer, device.id, device.secret); }) .then(() => handleSuccess()) .catch(err => handleError(err)); @@ -96,10 +103,7 @@ const PairingCodeDialog = (props) => { }; useEffect(() => { - if (server) { - apiClient = new ApiClient(server.pairingServiceUrl); - requestPairingCode(); - } + requestPairingCode(); return () => { if (apiClient) { @@ -109,72 +113,66 @@ const PairingCodeDialog = (props) => { }, [server]); return ( - -
    { - evt.preventDefault(); - // if (submittable) { - // document.activeElement.blur(); // attempt to hide soft keyboard on tablet - // this.setState({ submittable: false }); - - // } - // completePairing(pairingCode); - }} - > -

    - You must pair this device with this Server before you can securely exchange data. - This is a one-off process that allows your devices to identify each other. -

    - { - loading && - - + + { + loading ? +
    +

    Please acknowledge the pairing request within the Server app to continue. - -

    - +

    +
    +
    - - } - - { !loading && - - - Please type the code shown on the Server setup screen into the box below - -
    - -
    -
    - - +
    : +
    +

    + Please type the code shown on the Server setup screen into the box below. +

    + { + evt.preventDefault(); + if (submittable) { + document.activeElement.blur(); // attempt to hide soft keyboard on tablet + setState(prevState => ({ + ...prevState, + submittable: false, + })); + } + completePairing(pairingCode); + }} + > +
    + + - this.inputRef.current.clearForm()} - className="pairing-code-input__clear pairing-code-input__clear--small" - > - Clear - -
    - -
    -
    - } - - + + +
    + } + ); }; PairingCodeDialog.propTypes = { - handleClose: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired, handleSuccess: PropTypes.func.isRequired, }; @@ -185,7 +183,6 @@ PairingCodeDialog.defaultProps = { }; const mapStateToProps = state => ({ - // show: !!state.ui.showPairingCodeDialog, deviceName: state.deviceSettings.description, }); diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js new file mode 100644 index 0000000000..a8bd2e7c31 --- /dev/null +++ b/src/containers/StartScreen/ImportSection.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { motion, AnimatePresence } from 'framer-motion'; +import { GraphicButton, Button } from '@codaco/ui'; +import { Section } from '.'; +import { actionCreators as uiActions } from '../../ducks/modules/ui'; +import ProtocolUrlForm from './ProtocolUrlForm'; + +const ImportSection = (props) => { + const { + pairedServer, + installedProtocols, + toggleShowProtocolUrlForm, + showProtocolUrlForm, + } = props; + + return ( +
    +
    +
    +

    Import a Protocol

    +
    + + + +

    Import

    +

    From URL

    +
    + +

    Import

    +

    From File

    +
    + { + pairedServer && ( + +

    Import

    +

    From Server

    +
    + ) + } +
    +
    +
    + { Object.keys(installedProtocols).length > 0 && ( +
    + +
    + )} + +
    + ); +}; + +ImportSection.propTypes = { +}; + +ImportSection.defaultProps = { + pairedServer: null, +}; + +function mapStateToProps(state) { + return { + pairedServer: state.pairedServer, + installedProtocols: state.installedProtocols, + showProtocolUrlForm: state.ui.showProtocolUrlForm, + }; +} + +function mapDispatchToProps(dispatch) { + return { + toggleShowProtocolUrlForm: () => dispatch(uiActions.toggle('showProtocolUrlForm')), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ImportSection); + +export { ImportSection as UnconnectedImportSection }; diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 7d9fce770e..45417f5b79 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -1,31 +1,57 @@ -import React from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { motion, AnimatePresence } from 'framer-motion'; -import { GraphicButton, Button } from '@codaco/ui'; import { Section } from '.'; import { Scroller } from '../../components'; -import { ProtocolCard } from '../../components/Cards'; -import { actionCreators as uiActions } from '../../ducks/modules/ui'; -import ProtocolUrlForm from './ProtocolUrlForm'; +import { ProtocolCard, SessionCard } from '../../components/Cards'; +import { actionCreators as sessionActions } from '../../ducks/modules/sessions'; +import { getLastActiveSession } from '../../selectors/session'; +import NewSessionOverlay from './NewSessionOverlay'; +import StackButton from '../../components/StackButton'; const InterviewSection = (props) => { const { installedProtocols, - pairedServer, - showProtocolUrlForm, - toggleShowProtocolUrlForm, + sessions, + lastActiveSession, + addSession, } = props; - const otherProtocols = Object.keys(installedProtocols).slice(1); - const lastProtocol = installedProtocols[Object.keys(installedProtocols)[0]]; - console.log('pther', otherProtocols, installedProtocols); + const lastActiveProtocol = { + ...installedProtocols[Object.keys(installedProtocols)[0]], + protocolUID: Object.keys(installedProtocols)[0], + }; + + const [showNewSessionOverlay, setShowNewSessionOverlay] = useState(false); + const [selectedProtocol, setSelectedProtocol] = useState(null); + + const handleCloseOverlay = () => { + setShowNewSessionOverlay(false); + setSelectedProtocol(null); + }; + + const handleCreateSession = (caseId) => { + addSession(caseId, selectedProtocol); + handleCloseOverlay(); + }; + + const protocolCardClickHandler = (protocolUID) => { + setShowNewSessionOverlay(true); + setSelectedProtocol(protocolUID); + }; + + const sessionCardClickHandler = () => {}; + + if (Object.keys(installedProtocols).length === 0 && Object.keys(sessions).length === 0) { + return null; + } + return (
    - { Object.keys(installedProtocols).length > 0 && ( - +
    @@ -33,50 +59,28 @@ const InterviewSection = (props) => {

    Start an Interview

    protocolCardClickHandler(lastActiveProtocol.protocolUID) + } attributes={{ - schemaVersion: lastProtocol.schemaVersion, - lastModified: lastProtocol.lastModified, - installationDate: lastProtocol.installationDate, - name: lastProtocol.name, - description: lastProtocol.description, + schemaVersion: lastActiveProtocol.schemaVersion, + lastModified: lastActiveProtocol.lastModified, + installationDate: lastActiveProtocol.installationDate, + name: lastActiveProtocol.name, + description: lastActiveProtocol.description, }} />
    { - Object.keys(otherProtocols).length > 0 && ( + Object.keys(installedProtocols).length > 1 && (
    -

    Use Other Protocol

    - - - { - otherProtocols.map((protocol, protocolUID) => { - const { - schemaVersion, - lastModified, - installationDate, - name, - description, - } = installedProtocols[protocol]; - - console.log('protocol', protocol, installedProtocols[protocol]); - - return ( - - ); - }) - } - - + +

    +3 Sessions

    +
    ) } @@ -85,40 +89,41 @@ const InterviewSection = (props) => { ) } + { Object.keys(sessions).length > 0 && ( + +
    +
    +
    +
    +

    Resume last Interview

    +
    + +
    + { Object.keys(sessions).length > 1 && ( +
    + +

    +3 Sessions

    +
    +
    + )} +
    +
    +
    + )} -
    -
    -

    Import a Protocol

    -
    -
    - -

    Import

    -

    From URL

    -
    - -

    Import

    -

    From File

    -
    - { - pairedServer && ( - -

    Import

    -

    From Server

    -
    - ) - } -
    -
    -
    - -
    +
    ); }; @@ -132,14 +137,15 @@ InterviewSection.defaultProps = { function mapStateToProps(state) { return { installedProtocols: state.installedProtocols, - paredServer: state.pairedServer, showProtocolUrlForm: state.ui.showProtocolUrlForm, + sessions: state.sessions, + lastActiveSession: getLastActiveSession(state), }; } function mapDispatchToProps(dispatch) { return { - toggleShowProtocolUrlForm: () => dispatch(uiActions.toggle('showProtocolUrlForm')), + addSession: (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)), }; } diff --git a/src/containers/StartScreen/NewSessionOverlay.js b/src/containers/StartScreen/NewSessionOverlay.js new file mode 100644 index 0000000000..2310073783 --- /dev/null +++ b/src/containers/StartScreen/NewSessionOverlay.js @@ -0,0 +1,86 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from '@codaco/ui'; +import Overlay from '../../containers/Overlay'; +import { Form } from '../../containers'; + + +class NewSessionOverlay extends Component { + constructor(props) { + super(props); + + this.state = { + caseID: '', + }; + + this.overlay = React.createRef(); + } + + onSubmitForm = (fields) => { + this.props.handleSubmit(fields.case_id); + } + + render() { + const { show, onClose } = this.props; + + const formConfig = { + formName: 'case-id-form', + fields: [ + { + label: null, + name: 'case_id', + component: 'Text', + placeholder: 'Enter a unique case ID', + validation: { + required: true, + maxLength: 30, + }, + }, + ], + }; + + return ( + +
    +

    + Before the interview begins, enter a case ID. + This will be shown on the resume interview screen to help you quickly + identify this session. +

    +
    +
    + +
    +
    +
    +
    + ); + } +} + +NewSessionOverlay.propTypes = { + show: PropTypes.bool, + handleSubmit: PropTypes.func.isRequired, + onClose: PropTypes.func, +}; + +NewSessionOverlay.defaultProps = { + show: false, + onClose: () => {}, +}; + +export default NewSessionOverlay; diff --git a/src/containers/StartScreen/ProtocolUrlForm.js b/src/containers/StartScreen/ProtocolUrlForm.js index 3cfe40c724..660e6db44a 100644 --- a/src/containers/StartScreen/ProtocolUrlForm.js +++ b/src/containers/StartScreen/ProtocolUrlForm.js @@ -60,7 +60,7 @@ const ProtocolUrlForm = ({ autoFocus {...formConfig} > -
    +
    diff --git a/src/containers/StartScreen/ResumeSessionSection.js b/src/containers/StartScreen/ResumeSessionSection.js deleted file mode 100644 index 148fbdb27a..0000000000 --- a/src/containers/StartScreen/ResumeSessionSection.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { AnimatePresence } from 'framer-motion'; -import { SessionCard } from '../../components/Cards'; -import { actionCreators as uiActions } from '../../ducks/modules/ui'; -import { getLastActiveSession } from '../../selectors/session'; -import { Section } from '.'; - -const NewInterviewSection = (props) => { - const { - sessions, - lastActiveSession, - showSessionsOverlay, - } = props; - - const ResumeOtherSessionLabel = `+${Object.keys(sessions).length - 1} Other Interview${Object.keys(sessions).length - 1 > 1 ? 's' : ''}...`; - - return ( - - { Object.keys(sessions).length > 0 && ( -
    -
    -
    -

    Resume Last Interview

    -
    - - { Object.keys(sessions).length > 1 && ( - - )} -
    -
    - )} -
    - ); -}; - -NewInterviewSection.propTypes = { -}; - -NewInterviewSection.defaultProps = { -}; - -function mapStateToProps(state) { - return { - sessions: state.sessions, - lastActiveSession: getLastActiveSession(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - showSessionsOverlay: () => dispatch(uiActions.update({ showSessionsOverlay: true })), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(NewInterviewSection); - -export { NewInterviewSection as UnconnectedNewInterviewSection }; diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index a6a14961c6..0a9fc16605 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -1,28 +1,57 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { isValid } from 'redux-form'; +import { submit, isValid } from 'redux-form'; import { connect } from 'react-redux'; +import { motion, AnimatePresence } from 'framer-motion'; import { Button } from '@codaco/ui'; import { Form } from '../../containers/'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { isValidAddress, isValidPort, maxPort, minPort, addPairingUrlToService } from '../../utils/serverAddressing'; import { Overlay } from '../Overlay'; import PairingCodeDialog from '../Server/PairingCodeDialog'; +import { ExternalLink } from '../../components'; const FORM_NAME = 'server-address-form'; +const validateAddress = (address) => { + if (!address) { + return 'Please enter an IP address or domain name for Server.'; + } + + if (!isValidAddress(address)) { + return 'Please enter a valid IP address or DNS name.'; + } + + return undefined; +}; + +const validatePort = (port) => { + if (!port) { + return 'Please enter a port number.'; + } + + if (!isValidPort(port)) { + return `Please enter a valid port number (${minPort} - ${maxPort}).`; + } + + if (port < minPort || port > maxPort) { + return `Please enter a port in the range ${minPort} - ${maxPort}.`; + } + + return undefined; +}; + const ServerAddressForm = ({ show, handleClose, submittable, openDialog, + submitForm, }) => { const [showPairingCodeDialog, setShowPairingCodeDialog] = useState(false); const [selectedServer, setSelectedServer] = useState(null); const handleSubmit = (values) => { - console.log('settingserver', values); - const serverWithPairingUrl = addPairingUrlToService({ addresses: [values.serverAddress], port: values.serverPort, @@ -31,7 +60,6 @@ const ServerAddressForm = ({ setSelectedServer(serverWithPairingUrl); if (serverWithPairingUrl.pairingServiceUrl) { - console.log('serverWithPairingURL', serverWithPairingUrl); // Now show the pairing key setShowPairingCodeDialog(true); } else { @@ -43,34 +71,6 @@ const ServerAddressForm = ({ } }; - const validateAddress = (address) => { - if (!address) { - return 'Please enter an IP address or domain name for Server.'; - } - - if (!isValidAddress(address)) { - return 'Please enter a valid IP address or DNS name.'; - } - - return undefined; - }; - - const validatePort = (port) => { - if (!port) { - return 'Please enter a port number.'; - } - - if (!isValidPort(port)) { - return `Please enter a valid port number (${minPort} - ${maxPort}).`; - } - - if (port < minPort || port > maxPort) { - return `Please enter a port in the range ${minPort} - ${maxPort}.`; - } - - return undefined; - }; - const formConfig = { formName: FORM_NAME, fields: [ @@ -92,42 +92,62 @@ const ServerAddressForm = ({ }; const initialValues = { - serverPort: 61001, + serverPort: 51001, + serverAddress: 'localhost', }; + const RenderPairingForm = () => ( + +

    + If you cannot use automatic Server discovery, you may still be able to pair + this device with Server by manually entering connection details. +

    +

    + Enter the hostname or IP address of the computer running Server, along with + a port (either the default port, or a port you have opened through your firewall + and redirected to Server) and then click send pairing request. + The computer running Server must be reachable from this device using the details + you provide. +

    +

    + Visit our documentation article on pairing to learn more. +

    +
    +
    + +
    + + ); + + const renderPairingCode = () => ( + setShowPairingCodeDialog(false)} + handleSuccess={() => { setShowPairingCodeDialog(false); handleClose(); }} + /> + ); + return ( -

    - Enter the hostname or IP address of the computer running Server, and then - click send pairing request. The computer you want to request - pairing with must be reachable from this device using the details you provide. -

    - -
    - -
    - + + { showPairingCodeDialog ? renderPairingCode() : RenderPairingForm()} +
    - setShowPairingCodeDialog(false)} - handleSuccess={() => { setShowPairingCodeDialog(false); handleClose(); }} - />
    ); }; @@ -143,6 +163,7 @@ ServerAddressForm.propTypes = { const mapDispatchToProps = { openDialog: dialogActions.openDialog, + submitForm: () => submit(FORM_NAME), }; const mapStateToProps = state => ({ diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index f1288758b8..cb8c00a77a 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -5,11 +5,13 @@ import { actionCreators as uiActions } from '../../ducks/modules/ui'; import { Section } from '.'; import DiscoveredServerList from '../Server/DiscoveredServerList'; import ServerAddressForm from './ServerAddressForm'; +import { ExternalLink } from '../../components'; const ServerSection = (props) => { const { showServerAddressForm, toggleShowServerAddressForm, + pairedServer, } = props; return ( @@ -21,12 +23,25 @@ const ServerSection = (props) => {

    Server

    - + { !pairedServer ? ( + +

    + You must pair this device with this Server before you can securely exchange data. + This is a one-off process that allows your devices to identify each other. Visit + our documentation article on pairing to learn more. +

    + +
    + ) : (

    Server Card

    )}
    - - - + { !pairedServer ? ( + + ) : [ + , + , + ] + }
    diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index e416995d5b..284d16bf14 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -3,9 +3,9 @@ import { motion, AnimateSharedLayout } from 'framer-motion'; import { connect } from 'react-redux'; import { - ResumeSessionSection, HeaderSection, InterviewSection, + ImportSection, ServerSection, } from '.'; @@ -34,7 +34,7 @@ const StartScreen = () => { > - + diff --git a/src/containers/StartScreen/index.js b/src/containers/StartScreen/index.js index 72e0f6ef15..5c13d7ff85 100644 --- a/src/containers/StartScreen/index.js +++ b/src/containers/StartScreen/index.js @@ -1,7 +1,7 @@ export { default as Section } from './Section'; export { default as StartScreen } from './StartScreen'; -export { default as ResumeSessionSection } from './ResumeSessionSection'; export { default as InterviewSection } from './InterviewSection'; +export { default as ImportSection } from './ImportSection'; export { default as HeaderSection } from './HeaderSection'; export { default as ServerSection } from './ServerSection'; export { default as Switch } from './Switch'; diff --git a/src/styles/components/_overlay.scss b/src/styles/components/_overlay.scss index 53fdcda1a9..7bde6bf64d 100644 --- a/src/styles/components/_overlay.scss +++ b/src/styles/components/_overlay.scss @@ -7,17 +7,21 @@ $close-button-size: 1rem; position: relative; border-radius: .75rem; padding: 2rem; - width: 55rem; - max-height: calc(100% - 4rem); // + max-width: 90vw; + min-width: 60rem; + max-height: calc(100% - 4rem); margin: 2rem; display: flex; flex-direction: column; &__title { - text-align: center; + margin-bottom: 2.4rem; + display: flex; + align-items: center; + justify-content: center; h1 { - margin-top: 0; + margin: 0; } } @@ -29,7 +33,7 @@ $close-button-size: 1rem; &__close { position: absolute; - top: unit(4); + top: 2rem; right: unit(4); svg { diff --git a/src/styles/components/_pairing-code-input.scss b/src/styles/components/_pairing-code-input.scss index 33f560b38b..fb6f6b5687 100644 --- a/src/styles/components/_pairing-code-input.scss +++ b/src/styles/components/_pairing-code-input.scss @@ -1,5 +1,13 @@ .pairing-code-input { text-align: center; + display: flex; + background: var(--background); + padding: unit(4); + align-items: center; + justify-content: center; + flex-direction: column; + border-radius: var(--border-radius); + margin-bottom: unit(2); &__characters { display: inline-flex; @@ -9,7 +17,7 @@ } &__character { - --character-padding: .5rem; + --character-padding: .75rem; appearance: none; border-radius: .25rem; font-family: var(--code-font); @@ -19,7 +27,7 @@ text-align: center; width: calc(1rem + var(--character-padding) * 2); border: 0; - margin: .1rem; + margin: .25rem; &:nth-of-type(4n) { margin-right: 1rem; @@ -33,15 +41,4 @@ background-color: var(--error); } } - - &__clear { - color: var(--color-platinum--dark); - cursor: pointer; - letter-spacing: 3px; // match button - text-transform: uppercase; - - &:hover { - color: var(--color-white); - } - } } diff --git a/src/styles/components/settings-menu/_settings-menu.scss b/src/styles/components/settings-menu/_settings-menu.scss index 624ca4209e..ba38cf1492 100644 --- a/src/styles/components/settings-menu/_settings-menu.scss +++ b/src/styles/components/settings-menu/_settings-menu.scss @@ -10,7 +10,7 @@ display: flex; height: 100%; overflow: hidden; - background: var(--transparent-dark); + background: var(--modal-overlay); nav { background: var(--panel-bg-muted); diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 2af79db9c4..accb265812 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -17,8 +17,6 @@ position: absolute; top: 0; - - &__container { padding: unit(2) unit(8) unit(8); } @@ -28,7 +26,6 @@ border-radius: var(--border-radius); box-shadow: 0 unit(1) unit(1) 0 rgba(0, 0, 0, 0.25); margin: unit(6) auto; - background: var(--color-panel-grey); color: var(--color-charcoal); max-width: 65rem; width: 100%; @@ -91,7 +88,7 @@ .interview-section { &__manage-protocols { - background: var(--color-slate-blue--dark); + background: #cccccc; padding: unit(2) unit(6); display: flex; align-items: center; @@ -105,6 +102,7 @@ &__start-new { padding: 0 !important; + background: var(--color-panel-grey); .content-area { display: flex; @@ -120,46 +118,72 @@ } &__other { - flex: 0 1 50%; - width: 50%; - background: #dcdedd; - // color: white; + flex: 0 1 40%; display: flex; flex-direction: column; padding: unit(4) unit(6); + background: #ccc; - > h4 { - margin-top: 0; - margin-bottom: 0.6rem; + .stack-button { + h4 { + color: var(--text-dark); + } } + } + } + } - .scrollable { - flex: 1; - max-height: 20rem; + &__resume-section { + padding: 0 !important; + background: var(--color-slate-blue); + color: var(--color-white); - > .protocol-card { - margin: unit(2) 0; - } + .content-area { + display: flex; + + &__last-session { + padding: unit(4) unit(6); + flex: 1 1 auto; + + > .session-card { + margin: 0; + max-width: 35rem; } } + + &__other { + flex: 0 1 40%; + display: flex; + flex-direction: column; + padding: unit(4) unit(6); + background: #555bbc; + } } + } +} + +.import-section { + &__manage-protocols { + background: #cccccc; + padding: unit(2) unit(6); + display: flex; + align-items: center; + justify-content: flex-end; + color: var(--color-white); + h2 { + margin: 0; + } } &__install-section { - background: var(--color-slate-blue); - color: var(--color-white); + background: var(--color-panel-grey); padding: unit(4) unit(6); > .content-buttons { display: flex; justify-content: space-around; - > .graphic-button { - max-width: 20rem; - } - - > :first-child { margin-right: unit(2); } @@ -296,6 +320,7 @@ .server-section { &__main { + background: var(--color-panel-grey); padding: 0; .content-area { @@ -305,6 +330,15 @@ &__buttons { padding: unit(2) unit(6); background: var(--color-mustard); + display: flex; + align-items: center; + justify-content: flex-end; + + .button { + &:last-child { + margin-left: unit(2); + } + } } &__discover { @@ -314,7 +348,7 @@ > h4 { margin-top: 0; - margin-bottom: 0.6rem; + margin-bottom: 1.3rem; } .discovered-server-list { @@ -346,3 +380,166 @@ } } + +.server-address-form { + width: 60rem; + + &__form { + padding: unit(4); + background: var(--background); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row-reverse; + } + +} + +.pairing-form { + + &--loading { + display: flex; + background: var(--background); + padding: unit(4); + align-items: center; + justify-content: center; + flex-direction: column; + border-radius: var(--border-radius); + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + + .pairing-code-clear { + color: var(--color-platinum--dark); + cursor: pointer; + font-weight: 700; + font-size: 0.8rem; + letter-spacing: 0.15em; // match button + text-transform: uppercase; + + &:hover { + color: var(--color-white); + } + } + + .pairing-form-buttons { + .button { + &:last-child { + margin-left: unit(2); + } + } + } + } +} + +.protocol-url-form { + .form-field-container { + background: var(--background); + padding: unit(4); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + } +} + +.case-id-form { + width: 60rem; + + .form-field-container { + background: var(--background); + padding: unit(4); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + } +} + + +.stack-button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + color: white; + padding: unit(2); + height: 100%; + border-radius: 0.75rem; + cursor: pointer; + + &__label { + margin: 0; + padding-bottom: 1.2rem; + } + + &__content { + color: var(--text-dark); + position: absolute; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + margin-left: -0.5rem; + z-index: var(--z-default); + } + + + .stack-of-cards { + width: 13rem; + height: 9rem; + display: flex; + align-items: flex-end; + position: relative; + + .mock-card { + position: absolute; + width: 12rem; + height: 8rem; + border-radius: var(--border-radius); + background: var(--color-slate-blue--dark); + box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.3); + overflow: hidden; + + + .main { + width: 100%; + height: 5rem; + } + + &--one { + + } + + &--two { + margin-top: -0.5rem; + margin-left: 0.5rem; + opacity: 0.65; + + } + + &--three { + margin-top: -1rem; + margin-left: 1rem; + opacity: 0.35; + } + } + } +} diff --git a/src/utils/network-exporters b/src/utils/network-exporters index 2aaf920a73..76d4751fee 160000 --- a/src/utils/network-exporters +++ b/src/utils/network-exporters @@ -1 +1 @@ -Subproject commit 2aaf920a73f364851f28240f1c3a13c9fa421952 +Subproject commit 76d4751feec800c3e182680386289915d6058969 From 48621a01dd9148953318961662df64e412a4f89a Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Fri, 21 Aug 2020 14:29:22 +0100 Subject: [PATCH 26/71] WIP start session overlay --- package-lock.json | 39 ++---- package.json | 2 +- src/components/Cards/SessionCard.js | 3 - src/components/NewFilterableListWrapper.js | 130 ++++++++++++++++++ src/components/index.js | 1 + src/containers/Overlay.js | 8 +- .../StartScreen/InterviewSection.js | 17 ++- .../StartScreen/ResumeSessionOverlay.js | 80 +++++++++++ src/ducks/modules/deviceSettings.js | 4 +- src/styles/components/_all.scss | 1 + .../components/_new-filterable-list.scss | 82 +++++++++++ src/styles/containers/StartScreen/_all.scss | 4 +- src/utils/DeviceInfo.js | 7 +- 13 files changed, 325 insertions(+), 53 deletions(-) create mode 100644 src/components/NewFilterableListWrapper.js create mode 100644 src/containers/StartScreen/ResumeSessionOverlay.js create mode 100644 src/styles/components/_new-filterable-list.scss diff --git a/package-lock.json b/package-lock.json index 177336e79b..a59c5e1420 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2524,25 +2524,6 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, - "@popmotion/easing": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@popmotion/easing/-/easing-1.0.2.tgz", - "integrity": "sha512-IkdW0TNmRnWTeWI7aGQIVDbKXPWHVEYdGgd5ZR4SH/Ty/61p63jCjrPxX1XrR7IGkl08bjhJROStD7j+RKgoIw==", - "dev": true - }, - "@popmotion/popcorn": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@popmotion/popcorn/-/popcorn-0.4.4.tgz", - "integrity": "sha512-jYO/8319fKoNLMlY4ZJPiPu8Ea8occYwRZhxpaNn/kZsK4QG2E7XFlXZMJBsTWDw7I1i0uaqyC4zn1nwEezLzg==", - "dev": true, - "requires": { - "@popmotion/easing": "^1.0.1", - "framesync": "^4.0.1", - "hey-listen": "^1.0.8", - "style-value-types": "^3.1.7", - "tslib": "^1.10.0" - } - }, "@scarf/scarf": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.1.0.tgz", @@ -14126,17 +14107,15 @@ } }, "framer-motion": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.4.2.tgz", - "integrity": "sha512-1vJn/px3sSCsbkiH8IZXLeT2ZgVkQFNuX+mbDdMbqZAxlfkX4HMft4+HDLztQFYDUHieXya9vTe32DYMxsRLew==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.5.5.tgz", + "integrity": "sha512-23458Ylj8SVcm36M+NJg6xEv9p5p3K0HizNMiby6IfLa2Wf6fDUimjW9axrC/OGCjczNS1GkMK0BCGky0b3NFQ==", "dev": true, "requires": { "@emotion/is-prop-valid": "^0.8.2", - "@popmotion/easing": "^1.0.2", - "@popmotion/popcorn": "^0.4.2", "framesync": "^4.0.4", "hey-listen": "^1.0.8", - "popmotion": "9.0.0-beta-8", + "popmotion": "9.0.0-rc.7", "style-value-types": "^3.1.9", "tslib": "^1.10.0" } @@ -21204,16 +21183,14 @@ "dev": true }, "popmotion": { - "version": "9.0.0-beta-8", - "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.0.0-beta-8.tgz", - "integrity": "sha512-6eQzqursPvnP7ePvdfPeY4wFHmS3OLzNP8rJRvmfFfEIfpFqrQgLsM50Gd9AOvGKJtYJOFknNG+dsnzCpgIdAA==", + "version": "9.0.0-rc.7", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.0.0-rc.7.tgz", + "integrity": "sha512-sjPhOJtrQAQsSKsn5KKi0Q1E2pjnLozZ08m0zWX1OqPX3ERPH0txG2zwwHMnDoAHGHAkVujlAdFZ4ERLbKGMDA==", "dev": true, "requires": { - "@popmotion/easing": "^1.0.1", - "@popmotion/popcorn": "^0.4.2", "framesync": "^4.0.4", "hey-listen": "^1.0.8", - "style-value-types": "^3.1.6", + "style-value-types": "^3.1.9", "tslib": "^1.10.0" } }, diff --git a/package.json b/package.json index f6dc7e04db..7f9b6520d5 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "eventemitter3": "^3.1.0", "faker": "^4.1.0", "file-loader": "1.1.11", - "framer-motion": "^2.3.0", + "framer-motion": "^2.5.5", "fs-extra": "3.0.1", "fuse.js": "^3.4.5", "history": "^4.7.2", diff --git a/src/components/Cards/SessionCard.js b/src/components/Cards/SessionCard.js index dcecc6c467..713d98e614 100644 --- a/src/components/Cards/SessionCard.js +++ b/src/components/Cards/SessionCard.js @@ -31,8 +31,6 @@ const SessionCard = (props) => { setSession(sessionUUID); }; - console.log('sessioncard', props); - return ( { - console.log('mmm', state, props); const session = state.sessions[props.sessionUUID]; const protocol = get(state.installedProtocols, [session.protocolUID]); diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js new file mode 100644 index 0000000000..c3167164ee --- /dev/null +++ b/src/components/NewFilterableListWrapper.js @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import PropTypes from 'prop-types'; +import { Button } from '@codaco/ui'; +import { Text } from '@codaco/ui/lib/components/Fields'; +import Scroller from './Scroller'; +import { entityAttributesProperty } from '../ducks/modules/network'; +import sortOrder from '../utils/sortOrder'; +import { selectable } from '../behaviours'; +import { + DragSource, +} from '../behaviours/DragAndDrop'; + +const NewFilterableListWrapper = (props) => { + const { + items, + ItemComponent, + itemProperties, + initialSortProperty, + initialSortDirection, + sortableProperties, + } = props; + + const [filterTerm, setFilterTerm] = useState(null); + const [sortProperty, setSortProperty] = useState(initialSortProperty); + const [sortAscending, setSortAscending] = useState(initialSortDirection === 'asc'); + + const handleSetSortProperty = (property) => { + if (sortProperty === property) { + setSortAscending(!sortAscending); + } else { + setSortAscending(true); + setSortProperty(property); + } + }; + + const onFilterChange = event => setFilterTerm(event.target.value || null); + + const sortedItems = sortOrder([{ + property: sortProperty, + direction: sortAscending ? 'asc' : 'desc', + }])(items); + + const getFilteredAndSortedItemList = () => { + if (!filterTerm) { return sortedItems; } + + const normalizedFilterTerm = filterTerm.toLowerCase(); + + return sortedItems.filter( + (item) => { + const itemAttributes = Object.values(item[entityAttributesProperty]); + // Include in filtered list if any of the attribute property values + // include the filter value + return itemAttributes.some( + property => property.toString().toLowerCase().includes(normalizedFilterTerm), + ); + }, + ); + }; + + return ( + +
    +
    + { (sortableProperties && sortableProperties.length > 0) && +
    +

    Sort:

    + {sortableProperties.map(sortField => ( + + ))} +
    + } +
    +
    +

    Filter:

    + +
    +
    +
    + + { + getFilteredAndSortedItemList().map((item, index) => { + const EnhancedItem = DragSource(selectable(ItemComponent)); + return ( + + ); + }) + } + +
    +
    + ); +}; + +NewFilterableListWrapper.propTypes = { + ItemComponent: PropTypes.elementType.isRequired, + itemProperties: PropTypes.object, + items: PropTypes.array.isRequired, + initialSortProperty: PropTypes.string.isRequired, + initialSortDirection: PropTypes.oneOf(['asc', 'desc']), + sortableProperties: PropTypes.array, +}; + +NewFilterableListWrapper.defaultProps = { + initialSortDirection: 'asc', + itemProperties: null, + sortableProperties: [], +}; + +export default NewFilterableListWrapper; diff --git a/src/components/index.js b/src/components/index.js index 0b005b37ba..21604decec 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,6 +1,7 @@ export { default as Card } from './Card'; export { default as CardList } from './CardList'; export { default as FilterableListWrapper } from './FilterableListWrapper'; +export { default as NewFilterableListWrapper } from './NewFilterableListWrapper'; export { default as Scroller } from './Scroller'; export { default as Pips } from './Pips'; export { default as Prompt } from './Prompt'; diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index f6061ace18..35cddcdb6b 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -24,17 +24,17 @@ const Overlay = (props) => { return ( -
    + { title && (

    {title}

    )} -
    + {children} -
    +
    -
    +
    ); }; diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 45417f5b79..59d3be7199 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -2,12 +2,12 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import { motion, AnimatePresence } from 'framer-motion'; import { Section } from '.'; -import { Scroller } from '../../components'; import { ProtocolCard, SessionCard } from '../../components/Cards'; import { actionCreators as sessionActions } from '../../ducks/modules/sessions'; import { getLastActiveSession } from '../../selectors/session'; import NewSessionOverlay from './NewSessionOverlay'; import StackButton from '../../components/StackButton'; +import ResumeSessionOverlay from './ResumeSessionOverlay'; const InterviewSection = (props) => { const { @@ -79,7 +79,11 @@ const InterviewSection = (props) => { cardColor="var(--color-platinum)" insetColor="var(--color-slate-blue--dark)" > -

    +3 Sessions

    +

    + { + (Object.keys(installedProtocols).length - 1) > 1 ? `+${Object.keys(sessions).length - 1} Protocols` : `+${Object.keys(sessions).length - 1} Protocol` + } +

    ) @@ -90,7 +94,7 @@ const InterviewSection = (props) => { ) } { Object.keys(sessions).length > 0 && ( - +
    @@ -110,12 +114,17 @@ const InterviewSection = (props) => { cardColor="var(--color-platinum)" insetColor="var(--color-platinum--dark)" > -

    +3 Sessions

    +

    + { + (Object.keys(sessions).length - 1) > 1 ? `+${Object.keys(sessions).length - 1} Interviews` : `+${Object.keys(sessions).length - 1} Interview` + } +

    )}
    +
    )} diff --git a/src/containers/StartScreen/ResumeSessionOverlay.js b/src/containers/StartScreen/ResumeSessionOverlay.js new file mode 100644 index 0000000000..ddec42a04b --- /dev/null +++ b/src/containers/StartScreen/ResumeSessionOverlay.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { isEmpty } from 'lodash'; +import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { NewFilterableListWrapper } from '../../components'; +import { SessionCard } from '../../components/Cards'; +import { Overlay } from '../Overlay'; + +const oneBasedIndex = i => parseInt(i || 0, 10) + 1; + +const emptyView = ( +
    +
    +

    No previous interviews found

    +

    + You have no in-progress interview sessions available to resume. + To begin a new session, select a protocol from the main start screen. +

    +
    +
    +); + +const SessionList = ({ sessions }) => { + if (isEmpty(sessions)) { + return emptyView; + } + + const newSessions = [...Object.keys(sessions)].map(sessionUUID => ({ + sessionUUID, + })); + + return ( + {}} + title="Select an Interview to Resume" + > + + + ); +}; + +SessionList.propTypes = { + sessions: PropTypes.object.isRequired, +}; + +function mapStateToProps(state) { + return { + sessions: state.sessions, + }; +} + +function mapDispatchToProps(dispatch) { + return { + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SessionList); diff --git a/src/ducks/modules/deviceSettings.js b/src/ducks/modules/deviceSettings.js index dd153121c7..57a573fd8d 100644 --- a/src/ducks/modules/deviceSettings.js +++ b/src/ducks/modules/deviceSettings.js @@ -1,4 +1,4 @@ -import { deviceDescription, shouldUseDynamicScaling, shouldStartFullScreen } from '../../utils/DeviceInfo'; +import { deviceDescription, shouldUseDynamicScaling } from '../../utils/DeviceInfo'; const SET_DESCRIPTION = 'SETTINGS/SET_DESCRIPTION'; const SET_INTERFACE_SCALE = 'SETTINGS/SET_INTERFACE_SCALE'; @@ -15,7 +15,7 @@ const initialState = { useFullScreenForms: false, interfaceScale: 100, showScrollbars: false, - startFullScreen: shouldStartFullScreen(), + startFullScreen: false, // Start screen showGettingStarted: true, diff --git a/src/styles/components/_all.scss b/src/styles/components/_all.scss index 0e84b0aa6c..cd5ff2a5e5 100644 --- a/src/styles/components/_all.scss +++ b/src/styles/components/_all.scss @@ -10,6 +10,7 @@ @import 'form'; @import 'form-wizard'; @import 'error-boundary'; +@import 'new-filterable-list'; @import 'draggable-preview'; @import 'pairing-code-input'; @import 'panels'; diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss new file mode 100644 index 0000000000..be44e18131 --- /dev/null +++ b/src/styles/components/_new-filterable-list.scss @@ -0,0 +1,82 @@ +.new-filterable-list { + --transparent-background: #{transparentize(rgb(0, 0, 0), 0.8)}; + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + border-radius: var(--border-radius); + background: var(--transparent-background); + + .card-list { + padding-bottom: 0; + padding-top: 1rem; + } + + &__header { + display: flex; + flex: 0 1 auto; + align-items: center; + align-content: space-evenly; + justify-content: space-between; + + &-section { + + &--sort { + // display: flex; + overflow-x: auto; + flex: 1 1; + $opaque: rgba(0, 0, 0, 1); + $distance: unit(2); + -webkit-mask-image: linear-gradient(90deg, transparent, $opaque unit(2), $opaque calc(100% - #{unit(2)}), transparent 100%); + + .scroll-container { + white-space: nowrap; + padding: unit(2); + } + } + + &--filter { + flex: 0 0 auto; + padding-left: unit(2); + } + + button { + margin: 0 1rem; + } + + h4 { + display: inline; + } + + .form-field-container { + display: inline-block; + margin: 0 1rem; + } + + input { + margin-bottom: 0; + } + + &__filter { + width: 13rem; + + input { + margin-bottom: 0; + } + } + } + } + + &__main { + flex: 1 0; + display: flex; + flex-direction: column; + + >.scrollable { + padding: unit(2) 0; + display: grid; + grid-template-columns: 1fr 1fr; + } + + } +} diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index accb265812..1593fb01f2 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -112,8 +112,8 @@ flex: 1 1 auto; > .protocol-card { - margin: 0; max-width: 35rem; + margin: 0 auto; } } @@ -146,8 +146,8 @@ flex: 1 1 auto; > .session-card { - margin: 0; max-width: 35rem; + margin: 0 auto; } } diff --git a/src/utils/DeviceInfo.js b/src/utils/DeviceInfo.js index 4d0441cf0a..b016e4154a 100644 --- a/src/utils/DeviceInfo.js +++ b/src/utils/DeviceInfo.js @@ -1,5 +1,5 @@ /* globals device */ -import { isCordova, isElectron, isWindows } from './Environment'; +import { isCordova, isElectron } from './Environment'; const versioned = name => `${name} - ${device.version || '?'}`; @@ -77,14 +77,9 @@ const deviceDescription = () => { // Disable dynamic scaling on android because vmin is resized by software keyboard const shouldUseDynamicScaling = () => !isCordova(); -// Everything apart from Windows should start fullscreen. Windows full screen UI -// is too confusing for users. -const shouldStartFullScreen = () => !isWindows(); - export default deviceDescription; export { deviceDescription, shouldUseDynamicScaling, - shouldStartFullScreen, }; From 325c66137539d8c75bac1b2a279975a56b06a9aa Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 25 Aug 2020 00:07:11 +0100 Subject: [PATCH 27/71] wip filterable list wrapper --- src/components/NewFilterableListWrapper.js | 43 +++++++++++-------- src/containers/Overlay.js | 8 ++-- .../StartScreen/ResumeSessionOverlay.js | 36 +++++++++++++--- src/styles/components/_overlay.scss | 2 +- src/utils/sortOrder.js | 6 ++- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index c3167164ee..4291023799 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -1,21 +1,19 @@ import React, { useState } from 'react'; -import { motion } from 'framer-motion'; +import { get } from 'lodash'; +import objectHash from 'object-hash'; +import { motion, AnimatePresence } from 'framer-motion'; import PropTypes from 'prop-types'; import { Button } from '@codaco/ui'; import { Text } from '@codaco/ui/lib/components/Fields'; import Scroller from './Scroller'; import { entityAttributesProperty } from '../ducks/modules/network'; import sortOrder from '../utils/sortOrder'; -import { selectable } from '../behaviours'; -import { - DragSource, -} from '../behaviours/DragAndDrop'; const NewFilterableListWrapper = (props) => { const { items, + propertyPath, ItemComponent, - itemProperties, initialSortProperty, initialSortDirection, sortableProperties, @@ -39,7 +37,7 @@ const NewFilterableListWrapper = (props) => { const sortedItems = sortOrder([{ property: sortProperty, direction: sortAscending ? 'asc' : 'desc', - }])(items); + }], {}, propertyPath)(items); const getFilteredAndSortedItemList = () => { if (!filterTerm) { return sortedItems; } @@ -48,7 +46,9 @@ const NewFilterableListWrapper = (props) => { return sortedItems.filter( (item) => { - const itemAttributes = Object.values(item[entityAttributesProperty]); + const itemAttributes = + propertyPath ? Object.values(get(item, propertyPath, {})) + : Object.values(item); // Include in filtered list if any of the attribute property values // include the filter value return itemAttributes.some( @@ -98,14 +98,21 @@ const NewFilterableListWrapper = (props) => {
    - { - getFilteredAndSortedItemList().map((item, index) => { - const EnhancedItem = DragSource(selectable(ItemComponent)); - return ( - - ); - }) - } + + { + getFilteredAndSortedItemList().map((item, index) => ( + + + + )) + } +
    @@ -114,8 +121,8 @@ const NewFilterableListWrapper = (props) => { NewFilterableListWrapper.propTypes = { ItemComponent: PropTypes.elementType.isRequired, - itemProperties: PropTypes.object, items: PropTypes.array.isRequired, + propertyPath: PropTypes.string, initialSortProperty: PropTypes.string.isRequired, initialSortDirection: PropTypes.oneOf(['asc', 'desc']), sortableProperties: PropTypes.array, @@ -123,7 +130,7 @@ NewFilterableListWrapper.propTypes = { NewFilterableListWrapper.defaultProps = { initialSortDirection: 'asc', - itemProperties: null, + propertyPath: entityAttributesProperty, sortableProperties: [], }; diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index 35cddcdb6b..f6061ace18 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -24,17 +24,17 @@ const Overlay = (props) => { return ( - +
    { title && (

    {title}

    )} - +
    {children} - +
    -
    +
    ); }; diff --git a/src/containers/StartScreen/ResumeSessionOverlay.js b/src/containers/StartScreen/ResumeSessionOverlay.js index ddec42a04b..9eff9555f1 100644 --- a/src/containers/StartScreen/ResumeSessionOverlay.js +++ b/src/containers/StartScreen/ResumeSessionOverlay.js @@ -2,12 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { isEmpty } from 'lodash'; +import { isEmpty, get } from 'lodash'; +import { SessionCard } from '@codaco/ui/lib/components/Cards'; import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { NewFilterableListWrapper } from '../../components'; -import { SessionCard } from '../../components/Cards'; + import { Overlay } from '../Overlay'; +import { entityAttributesProperty } from '../../ducks/modules/network'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; @@ -23,14 +25,28 @@ const emptyView = ( ); -const SessionList = ({ sessions }) => { +const SessionList = ({ sessions, installedProtocols }) => { if (isEmpty(sessions)) { return emptyView; } - const newSessions = [...Object.keys(sessions)].map(sessionUUID => ({ - sessionUUID, - })); + const newSessions = [...Object.keys(sessions)].map((sessionUUID) => { + const session = sessions[sessionUUID]; + const protocol = get(installedProtocols, [session.protocolUID]); + + const progress = Math.round( + (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, + ); + + return { + caseId: session.caseId, + startedAt: session.startedAt, + updatedAt: session.updatedAt, + protocolName: protocol.name, + progress, + onClickHandler: () => console.log('cleek'), + }; + }); return ( { ({ + // ...item, + // [entityAttributesProperty]: { + // ...item, + // }, + // })} + propertyPath={null} initialSortProperty="updatedAt" initialSortDirection="desc" sortableProperties={[ @@ -69,6 +92,7 @@ SessionList.propTypes = { function mapStateToProps(state) { return { sessions: state.sessions, + installedProtocols: state.installedProtocols, }; } diff --git a/src/styles/components/_overlay.scss b/src/styles/components/_overlay.scss index 7bde6bf64d..9902277a75 100644 --- a/src/styles/components/_overlay.scss +++ b/src/styles/components/_overlay.scss @@ -9,7 +9,7 @@ $close-button-size: 1rem; padding: 2rem; max-width: 90vw; min-width: 60rem; - max-height: calc(100% - 4rem); + max-height: calc(100vh - 4rem); margin: 2rem; display: flex; flex-direction: column; diff --git a/src/utils/sortOrder.js b/src/utils/sortOrder.js index da9875e6a6..a7b56516f3 100644 --- a/src/utils/sortOrder.js +++ b/src/utils/sortOrder.js @@ -18,7 +18,7 @@ const fifo = ({ createdIndex }) => createdIndex; * at: `codebook.node[nodeType]variables` * TODO: Use variable registry to respect variable type? */ -const sortOrder = (sortConfig = [], codebook = {}) => { // eslint-disable-line +const sortOrder = (sortConfig = [], codebook = {}, attributePath = entityAttributesProperty) => { // eslint-disable-line // '*' is a special prop to sort by the order in which nodes were added to the network const isFifoLifo = rule => rule.property === '*'; @@ -36,13 +36,15 @@ const sortOrder = (sortConfig = [], codebook = {}) => { // eslint-disable-line )); } + const rulePath = rule => (attributePath ? `${attributePath}.${rule.property}` : rule.property); + /** * Returns a list of sorted items * @param {array} items - A list of items (nodes) to be sorted */ return items => orderBy( items, - sortRules.map(rule => `${entityAttributesProperty}.${rule.property}`), + sortRules.map(rulePath), orders, ); }; From 028e5be247ec7b3cd1b83e9c48f771d31ed72a50 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 25 Aug 2020 21:12:28 +0100 Subject: [PATCH 28/71] functioning session overlay --- package-lock.json | 21 +++-- package.json | 4 +- src/components/NewFilterableListWrapper.js | 80 ++++++++++++++----- .../SettingsMenu/Sections/TabItemVariants.js | 10 +-- src/components/SettingsMenu/SettingsMenu.js | 35 ++++---- src/components/StackButton.js | 3 +- src/containers/Overlay.js | 1 - .../StartScreen/InterviewSection.js | 26 ++++-- ...ssionOverlay.js => ResumeSessionPicker.js} | 16 ++-- src/containers/StartScreen/ServerSection.js | 2 +- .../components/_new-filterable-list.scss | 5 +- src/styles/containers/StartScreen/_all.scss | 29 +++---- 12 files changed, 135 insertions(+), 97 deletions(-) rename src/containers/StartScreen/{ResumeSessionOverlay.js => ResumeSessionPicker.js} (87%) diff --git a/package-lock.json b/package-lock.json index a59c5e1420..3023d6037e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11626,9 +11626,9 @@ } }, "electron": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-9.2.0.tgz", - "integrity": "sha512-4ecZ3rcGg//Gk4fAK3Jo61T+uh36JhU6HHR/PTujQqQiBw1g4tNPd4R2hGGth2d+7FkRIs5GdRNef7h64fQEMw==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-9.2.1.tgz", + "integrity": "sha512-ZsetaQjXB8+9/EFW1FnfK4ukpkwXCxMEaiKiUZhZ0ZLFlLnFCpe0Bg4vdDf7e4boWGcnlgN1jAJpBw7w0eXuqA==", "dev": true, "requires": { "@electron/get": "^1.0.1", @@ -14107,9 +14107,9 @@ } }, "framer-motion": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.5.5.tgz", - "integrity": "sha512-23458Ylj8SVcm36M+NJg6xEv9p5p3K0HizNMiby6IfLa2Wf6fDUimjW9axrC/OGCjczNS1GkMK0BCGky0b3NFQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.6.0.tgz", + "integrity": "sha512-burXD+3V+bE0PsIopS7+EKiqtBv0bxCi0mK/y1mXCMlN8LqHnaiNFbgaVGPEYb8gcPtSeHW9Cu/8yb8kNxukiQ==", "dev": true, "requires": { "@emotion/is-prop-valid": "^0.8.2", @@ -14121,13 +14121,12 @@ } }, "framesync": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/framesync/-/framesync-4.0.4.tgz", - "integrity": "sha512-mdP0WvVHe0/qA62KG2LFUAOiWLng5GLpscRlwzBxu2VXOp6B8hNs5C5XlFigsMgrfDrr2YbqTsgdWZTc4RXRMQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/framesync/-/framesync-4.1.0.tgz", + "integrity": "sha512-MmgZ4wCoeVxNbx2xp5hN/zPDCbLSKiDt4BbbslK7j/pM2lg5S0vhTNv1v8BCVb99JPIo6hXBFdwzU7Q4qcAaoQ==", "dev": true, "requires": { - "hey-listen": "^1.0.8", - "tslib": "^1.10.0" + "hey-listen": "^1.0.5" } }, "fresh": { diff --git a/package.json b/package.json index 7f9b6520d5..4b70c26b8e 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "csvtojson": "^2.0.8", "detect-port": "1.1.0", "dotenv": "4.0.0", - "electron": "^9.2.0", + "electron": "^9.2.1", "electron-builder": "^22.7.0", "electron-log": "^4.2.2", "electron-notarize": "^1.0.0", @@ -97,7 +97,7 @@ "eventemitter3": "^3.1.0", "faker": "^4.1.0", "file-loader": "1.1.11", - "framer-motion": "^2.5.5", + "framer-motion": "^2.6.0", "fs-extra": "3.0.1", "fuse.js": "^3.4.5", "history": "^4.7.2", diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index 4291023799..bfa76933a4 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -1,11 +1,10 @@ import React, { useState } from 'react'; import { get } from 'lodash'; import objectHash from 'object-hash'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; import PropTypes from 'prop-types'; import { Button } from '@codaco/ui'; import { Text } from '@codaco/ui/lib/components/Fields'; -import Scroller from './Scroller'; import { entityAttributesProperty } from '../ducks/modules/network'; import sortOrder from '../utils/sortOrder'; @@ -58,8 +57,41 @@ const NewFilterableListWrapper = (props) => { ); }; + const containerVariants = { + visible: { + opacity: 1, + transition: { + when: 'beforeChildren', + staggerChildren: 0.1, + }, + }, + hidden: { + opacity: 0, + transition: { + when: 'afterChildren', + }, + }, + }; + + const itemVariants = { + visible: { + opacity: 1, + y: 0, + transition: { + type: 'spring', + }, + }, + hidden: { + opacity: 0, + y: 25, + transition: { + type: 'spring', + }, + }, + }; + return ( -
    @@ -97,25 +129,31 @@ const NewFilterableListWrapper = (props) => {
    - - - { - getFilteredAndSortedItemList().map((item, index) => ( - - - - )) - } - - + + + + { + getFilteredAndSortedItemList().map(item => ( + + + + )) + } + + + +
    -
    + ); }; diff --git a/src/components/SettingsMenu/Sections/TabItemVariants.js b/src/components/SettingsMenu/Sections/TabItemVariants.js index 11dea217c1..12e3cb03c3 100644 --- a/src/components/SettingsMenu/Sections/TabItemVariants.js +++ b/src/components/SettingsMenu/Sections/TabItemVariants.js @@ -1,17 +1,11 @@ const TabItemVariants = { visible: { - y: 0, + x: 0, opacity: 1, - transition: { - y: { stiffness: 500, velocity: -100 }, - }, }, hidden: { - y: 50, + x: -50, opacity: 0, - transition: { - y: { stiffness: 500 }, - }, }, }; diff --git a/src/components/SettingsMenu/SettingsMenu.js b/src/components/SettingsMenu/SettingsMenu.js index 3908005486..2130d5dc36 100644 --- a/src/components/SettingsMenu/SettingsMenu.js +++ b/src/components/SettingsMenu/SettingsMenu.js @@ -20,7 +20,7 @@ const SettingsMenu = (props) => { settingsMenuOpen, } = props; - const baseAnimationDuration = getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; + const getAnimationDuration = variable => getCSSVariableAsNumber(variable) / 1000; const baseAnimationEasing = getCSSVariableAsString('--animation-easing-json'); const tabs = { @@ -37,7 +37,7 @@ const SettingsMenu = (props) => { transition: { when: 'beforeChildren', staggerChildren: 0.07, - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), easing: baseAnimationEasing, }, }, @@ -45,7 +45,7 @@ const SettingsMenu = (props) => { opacity: 0, transition: { when: 'afterChildren', - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), easing: baseAnimationEasing, }, }, @@ -56,8 +56,8 @@ const SettingsMenu = (props) => { x: '0%', transition: { when: 'beforeChildren', - staggerChildren: 0.07, - duration: baseAnimationDuration, + staggerChildren: 0.2, + duration: getAnimationDuration('--animation-duration-standard-ms'), easing: baseAnimationEasing, }, }, @@ -67,7 +67,7 @@ const SettingsMenu = (props) => { when: 'afterChildren', staggerChildren: 0.05, staggerDirection: -1, - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-standard-ms'), easing: baseAnimationEasing, }, }, @@ -75,18 +75,17 @@ const SettingsMenu = (props) => { const navVariants = { show: { - y: '0%', + x: '0%', opacity: 1, transition: { - duration: baseAnimationDuration, - easing: baseAnimationEasing, + type: 'spring', }, }, hide: { - y: '20%', + x: '-10%', opacity: 0, transition: { - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), easing: baseAnimationEasing, }, }, @@ -97,8 +96,8 @@ const SettingsMenu = (props) => { opacity: 1, transition: { when: 'beforeChildren', - duration: baseAnimationDuration, - delay: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), + delay: getAnimationDuration('--animation-duration-standard-ms'), easing: baseAnimationEasing, }, }, @@ -106,7 +105,7 @@ const SettingsMenu = (props) => { opacity: 0, transition: { when: 'afterChildren', - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-standard-ms'), easing: baseAnimationEasing, }, }, @@ -116,17 +115,17 @@ const SettingsMenu = (props) => { hidden: { opacity: 0, transition: { - staggerChildren: 0.05, + staggerChildren: 0.1, staggerDirection: -1, - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), }, }, visible: { opacity: 1, transition: { - staggerChildren: 0.05, + staggerChildren: 0.1, delayChildren: 0.2, - duration: baseAnimationDuration, + duration: getAnimationDuration('--animation-duration-very-fast-ms'), }, }, }; diff --git a/src/components/StackButton.js b/src/components/StackButton.js index cd93fb1e61..6cc448c4c9 100644 --- a/src/components/StackButton.js +++ b/src/components/StackButton.js @@ -34,9 +34,10 @@ const StackOfCards = ({ children, ...rest }) => ( ); -const StackButton = ({ cardColor, insetColor, label, children }) => ( +const StackButton = ({ cardColor, insetColor, label, children, clickHandler }) => (

    {label}

    diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index f6061ace18..e9e520a8df 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -1,7 +1,6 @@ import React from 'react'; import cx from 'classnames'; import { connect } from 'react-redux'; -import { motion } from 'framer-motion'; import PropTypes from 'prop-types'; import { Modal } from '@codaco/ui'; import { CloseButton } from '../components'; diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 59d3be7199..4f95ca6e91 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -4,10 +4,11 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Section } from '.'; import { ProtocolCard, SessionCard } from '../../components/Cards'; import { actionCreators as sessionActions } from '../../ducks/modules/sessions'; +import { actionCreators as uiActions } from '../../ducks/modules/ui'; import { getLastActiveSession } from '../../selectors/session'; import NewSessionOverlay from './NewSessionOverlay'; import StackButton from '../../components/StackButton'; -import ResumeSessionOverlay from './ResumeSessionOverlay'; +import ResumeSessionPicker from './ResumeSessionPicker'; const InterviewSection = (props) => { const { @@ -15,6 +16,8 @@ const InterviewSection = (props) => { sessions, lastActiveSession, addSession, + showResumeSessionPicker, + toggleShowResumeSessionPicker, } = props; const lastActiveProtocol = { @@ -81,7 +84,7 @@ const InterviewSection = (props) => { >

    { - (Object.keys(installedProtocols).length - 1) > 1 ? `+${Object.keys(sessions).length - 1} Protocols` : `+${Object.keys(sessions).length - 1} Protocol` + (Object.keys(installedProtocols).length - 1) > 1 ? `+${Object.keys(installedProtocols).length - 1} Protocols` : `+${Object.keys(installedProtocols).length - 1} Protocol` }

    @@ -90,6 +93,11 @@ const InterviewSection = (props) => { } +
    ) } @@ -113,6 +121,7 @@ const InterviewSection = (props) => { label="Resume other interview" cardColor="var(--color-platinum)" insetColor="var(--color-platinum--dark)" + clickHandler={toggleShowResumeSessionPicker} >

    { @@ -124,15 +133,13 @@ const InterviewSection = (props) => { )} - + )} - ); }; @@ -147,6 +154,8 @@ function mapStateToProps(state) { return { installedProtocols: state.installedProtocols, showProtocolUrlForm: state.ui.showProtocolUrlForm, + showNewSessionOverlay: state.ui.showNewSessionOverlay, + showResumeSessionPicker: state.ui.showResumeSessionPicker, sessions: state.sessions, lastActiveSession: getLastActiveSession(state), }; @@ -155,6 +164,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { addSession: (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)), + toggleShowResumeSessionPicker: () => dispatch(uiActions.toggle('showResumeSessionPicker')), }; } diff --git a/src/containers/StartScreen/ResumeSessionOverlay.js b/src/containers/StartScreen/ResumeSessionPicker.js similarity index 87% rename from src/containers/StartScreen/ResumeSessionOverlay.js rename to src/containers/StartScreen/ResumeSessionPicker.js index 9eff9555f1..61eb0e53cb 100644 --- a/src/containers/StartScreen/ResumeSessionOverlay.js +++ b/src/containers/StartScreen/ResumeSessionPicker.js @@ -25,7 +25,7 @@ const emptyView = ( ); -const SessionList = ({ sessions, installedProtocols }) => { +const ResumeSessionPicker = ({ show, onClose, sessions, installedProtocols }) => { if (isEmpty(sessions)) { return emptyView; } @@ -50,19 +50,13 @@ const SessionList = ({ sessions, installedProtocols }) => { return ( {}} + show={show} + onClose={onClose} title="Select an Interview to Resume" > ({ - // ...item, - // [entityAttributesProperty]: { - // ...item, - // }, - // })} propertyPath={null} initialSortProperty="updatedAt" initialSortDirection="desc" @@ -85,7 +79,7 @@ const SessionList = ({ sessions, installedProtocols }) => { ); }; -SessionList.propTypes = { +ResumeSessionPicker.propTypes = { sessions: PropTypes.object.isRequired, }; @@ -101,4 +95,4 @@ function mapDispatchToProps(dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(SessionList); +export default connect(mapStateToProps, mapDispatchToProps)(ResumeSessionPicker); diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index cb8c00a77a..1f066448e7 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -36,7 +36,7 @@ const ServerSection = (props) => {
    { !pairedServer ? ( - + ) : [ , , diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index be44e18131..db86392646 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -71,8 +71,11 @@ flex: 1 0; display: flex; flex-direction: column; + height: 60vh; - >.scrollable { + >.test { + height: 100%; + overflow-y: auto; padding: unit(2) 0; display: grid; grid-template-columns: 1fr 1fr; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 1593fb01f2..94b167e7f0 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -88,12 +88,10 @@ .interview-section { &__manage-protocols { - background: #cccccc; padding: unit(2) unit(6); display: flex; align-items: center; justify-content: flex-end; - color: var(--color-white); h2 { margin: 0; @@ -103,7 +101,7 @@ &__start-new { padding: 0 !important; background: var(--color-panel-grey); - + color: var(--text-dark); .content-area { display: flex; @@ -121,8 +119,7 @@ flex: 0 1 40%; display: flex; flex-direction: column; - padding: unit(4) unit(6); - background: #ccc; + margin-top: 1.8rem; .stack-button { h4 { @@ -135,8 +132,8 @@ &__resume-section { padding: 0 !important; - background: var(--color-slate-blue); - color: var(--color-white); + background: #cccccc; + color: var(--text-dark); .content-area { display: flex; @@ -155,21 +152,27 @@ flex: 0 1 40%; display: flex; flex-direction: column; - padding: unit(4) unit(6); - background: #555bbc; + margin-top: 1.8rem; + + .stack-button { + h4 { + color: var(--text-dark); + } + } } } } } .import-section { + background: var(--color-slate-blue); + &__manage-protocols { - background: #cccccc; + background: var(--color-slate-blue--dark); padding: unit(2) unit(6); display: flex; align-items: center; justify-content: flex-end; - color: var(--color-white); h2 { margin: 0; @@ -177,8 +180,8 @@ } &__install-section { - background: var(--color-panel-grey); padding: unit(4) unit(6); + color: var(--color-white); > .content-buttons { display: flex; @@ -479,9 +482,7 @@ justify-content: center; flex-direction: column; color: white; - padding: unit(2); height: 100%; - border-radius: 0.75rem; cursor: pointer; &__label { From 31b0f0784103ee0978eecc9755c10c1557e8e8cd Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 25 Aug 2020 23:22:53 +0100 Subject: [PATCH 29/71] replace obsolete router system --- package-lock.json | 103 +++++++++--------- package.json | 7 +- .../SettingsMenu/Sections/DeveloperTools.js | 2 +- .../SettingsMenu/Sections/Pairing.js | 2 +- src/containers/Interfaces/EgoForm.js | 2 +- .../StartScreen/NewSessionOverlay.js | 1 - src/containers/StartScreen/StartScreen.js | 21 +++- src/ducks/modules/navigate.js | 2 +- src/ducks/modules/remote.js | 2 +- src/ducks/modules/rootReducer.js | 12 +- src/ducks/modules/session.js | 2 +- src/ducks/store.js | 8 +- src/index.js | 2 +- src/routes.js | 57 +++++++--- src/styles/components/_all.scss | 1 + src/styles/components/_protocol.scss | 11 ++ src/styles/containers/StartScreen/_all.scss | 1 - 17 files changed, 141 insertions(+), 95 deletions(-) create mode 100644 src/styles/components/_protocol.scss diff --git a/package-lock.json b/package-lock.json index 3023d6037e..e8e88a3ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5428,6 +5428,15 @@ "integrity": "sha1-5R0X+PDvDbkKZP20feMFFVbp8Wk=", "dev": true }, + "connected-react-router": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.8.0.tgz", + "integrity": "sha512-E64/6krdJM3Ag3MMmh2nKPtMbH15s3JQDuaYJvOVXzu6MbHbDyIvuwLOyhQIuP4Om9zqEfZYiVyflROibSsONg==", + "dev": true, + "requires": { + "prop-types": "^15.7.2" + } + }, "console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -19705,6 +19714,16 @@ "dom-walk": "^0.1.0" } }, + "mini-create-react-context": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz", + "integrity": "sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "tiny-warning": "^1.0.3" + } + }, "mini-css-extract-plugin": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.2.tgz", @@ -22317,18 +22336,16 @@ } }, "react-redux": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz", - "integrity": "sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.1.tgz", + "integrity": "sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==", "dev": true, "requires": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.5.5", "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4", - "loose-envify": "^1.1.0", - "prop-types": "^15.6.1", - "react-is": "^16.6.0", - "react-lifecycles-compat": "^3.0.0" + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.9.0" }, "dependencies": { "hoist-non-react-statics": { @@ -22343,65 +22360,47 @@ } }, "react-router": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz", - "integrity": "sha512-yrvL8AogDh2X42Dt9iknk4wF4V8bWREPirFfS9gLU1huk6qK41sg7Z/1S81jjTrGHxa3B8R3J6xIkDAA6CVarg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", + "integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", "dev": true, "requires": { - "history": "^4.7.2", - "hoist-non-react-statics": "^2.5.0", - "invariant": "^2.2.4", + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.1", - "warning": "^4.0.1" + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" }, "dependencies": { - "warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "dev": true, "requires": { - "loose-envify": "^1.0.0" + "react-is": "^16.7.0" } } } }, "react-router-dom": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.3.1.tgz", - "integrity": "sha512-c/MlywfxDdCp7EnB7YfPMOfMD3tOtIjrQlj/CKfNMBxdmpJP8xcz5P/UAFn3JbnQCNUxsHyVVqllF9LhgVyFCA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", + "integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", "dev": true, "requires": { - "history": "^4.7.2", - "invariant": "^2.2.4", + "@babel/runtime": "^7.1.2", + "history": "^4.9.0", "loose-envify": "^1.3.1", - "prop-types": "^15.6.1", - "react-router": "^4.3.1", - "warning": "^4.0.1" - }, - "dependencies": { - "warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, - "requires": { - "loose-envify": "^1.0.0" - } - } - } - }, - "react-router-redux": { - "version": "5.0.0-alpha.9", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.9.tgz", - "integrity": "sha512-euSgNIANnRXr4GydIuwA7RZCefrLQzIw5WdXspS8NPYbV+FxrKSS9MKG7U9vb6vsKHONnA4VxrVNWfnMUnUQAw==", - "dev": true, - "requires": { - "history": "^4.7.2", - "prop-types": "^15.6.0", - "react-router": "^4.2.0" + "prop-types": "^15.6.2", + "react-router": "5.2.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" } }, "react-test-renderer": { diff --git a/package.json b/package.json index 4b70c26b8e..086f7d3cf3 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "color": "^2.0.0", "concaveman": "^1.2.0", "connect-history-api-fallback": "1.3.0", + "connected-react-router": "^6.8.0", "cordova": "^8.1.2", "cross-env": "^5.2.0", "css-loader": "^3.4.2", @@ -127,10 +128,8 @@ "react-flip-toolkit": "^5.0.5", "react-id-swiper": "^2.4.0", "react-markdown": "^3.6.0", - "react-redux": "^5.0.7", - "react-router-dom": "^4.3.1", - "react-router-redux": "^5.0.0-alpha.9", - "react-test-renderer": "^16.5.2", + "react-redux": "^7.2.1", + "react-router-dom": "^5.2.0", "react-transform-hmr": "^1.0.4", "react-transition-group": "^2.5.0", "react-virtualized": "^9.21.1", diff --git a/src/components/SettingsMenu/Sections/DeveloperTools.js b/src/components/SettingsMenu/Sections/DeveloperTools.js index 6f7b6d1a8d..8c2edb9ebe 100644 --- a/src/components/SettingsMenu/Sections/DeveloperTools.js +++ b/src/components/SettingsMenu/Sections/DeveloperTools.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { withHandlers, compose } from 'recompose'; -import { push } from 'react-router-redux'; +import { push } from 'connected-react-router'; import { motion } from 'framer-motion'; import { Button } from '@codaco/ui/lib/components'; import { Number } from '@codaco/ui/lib/components/Fields'; diff --git a/src/components/SettingsMenu/Sections/Pairing.js b/src/components/SettingsMenu/Sections/Pairing.js index 571b2e2410..8692ba5c1d 100644 --- a/src/components/SettingsMenu/Sections/Pairing.js +++ b/src/components/SettingsMenu/Sections/Pairing.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'react-router-redux'; +import { push } from 'connected-react-router'; import { motion } from 'framer-motion'; import { withHandlers, compose } from 'recompose'; import { Text } from '@codaco/ui/lib/components/Fields'; diff --git a/src/containers/Interfaces/EgoForm.js b/src/containers/Interfaces/EgoForm.js index 51473b49b2..1412d4819e 100644 --- a/src/containers/Interfaces/EgoForm.js +++ b/src/containers/Interfaces/EgoForm.js @@ -184,7 +184,7 @@ const mapDispatchToProps = { openDialog: dialogActions.openDialog, }; -const withStore = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true }); +const withStore = connect(mapStateToProps, mapDispatchToProps); export { EgoForm }; diff --git a/src/containers/StartScreen/NewSessionOverlay.js b/src/containers/StartScreen/NewSessionOverlay.js index 2310073783..47b5d46e22 100644 --- a/src/containers/StartScreen/NewSessionOverlay.js +++ b/src/containers/StartScreen/NewSessionOverlay.js @@ -43,7 +43,6 @@ class NewSessionOverlay extends Component { diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 284d16bf14..61015c0b94 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -1,7 +1,7 @@ import React from 'react'; import { motion, AnimateSharedLayout } from 'framer-motion'; import { connect } from 'react-redux'; - +import { Redirect } from 'react-router-dom'; import { HeaderSection, InterviewSection, @@ -9,7 +9,11 @@ import { ServerSection, } from '.'; -const StartScreen = () => { + +const StartScreen = ({ + activeSessionId, + sessions, +}) => { const opacity = { visible: { opacity: 1, @@ -23,6 +27,14 @@ const StartScreen = () => { }, }; + if (activeSessionId) { + console.log('a', activeSessionId, sessions); + const stageIndex = sessions[activeSessionId].stageIndex; + const pathname = `/session/${activeSessionId}/${stageIndex}`; + console.log('active', activeSessionId, stageIndex, pathname); + return (); + } + return (
    @@ -53,7 +65,10 @@ const mapDispatchToProps = { }; -const mapStateToProps = () => ({ +const mapStateToProps = state => ({ + activeSessionId: state.activeSessionId, + sessions: state.sessions, + }); export default connect(mapStateToProps, mapDispatchToProps)(StartScreen); diff --git a/src/ducks/modules/navigate.js b/src/ducks/modules/navigate.js index a5ebf2778a..c5b73f965f 100644 --- a/src/ducks/modules/navigate.js +++ b/src/ducks/modules/navigate.js @@ -1,6 +1,6 @@ /* eslint-disable import/prefer-default-export */ -import { push } from 'react-router-redux'; +import { push } from 'connected-react-router'; import { isStageSkipped } from '../../selectors/skip-logic'; import { getSessionPath, getSessionProgress } from '../../selectors/session'; import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; diff --git a/src/ducks/modules/remote.js b/src/ducks/modules/remote.js index a2efe9f91d..3da3ac00ea 100644 --- a/src/ducks/modules/remote.js +++ b/src/ducks/modules/remote.js @@ -1,6 +1,6 @@ /* eslint-disable import/prefer-default-export */ -import { push } from 'react-router-redux'; +import { push } from 'connected-react-router'; import { actionCreators as resetActions } from './reset'; import { actionCreators as importProtocolActions } from './importProtocol'; import { actionCreators as sessionActions } from './sessions'; diff --git a/src/ducks/modules/rootReducer.js b/src/ducks/modules/rootReducer.js index 7d38f06866..f393a0499e 100644 --- a/src/ducks/modules/rootReducer.js +++ b/src/ducks/modules/rootReducer.js @@ -1,5 +1,5 @@ import { combineReducers } from 'redux'; -import { routerReducer } from 'react-router-redux'; +import { connectRouter } from 'connected-react-router'; import { reducer as formReducer } from 'redux-form'; import sessions from './sessions'; @@ -15,8 +15,8 @@ import ui from './ui'; import pairedServer from './pairedServer'; import { actionTypes as resetActionTypes } from './reset'; -const appReducer = combineReducers({ - router: routerReducer, +const appReducer = history => combineReducers({ + router: connectRouter(history), form: formReducer, activeSessionId, activeSessionWorkers, @@ -31,14 +31,14 @@ const appReducer = combineReducers({ pairedServer, }); -const rootReducer = (state, action) => { +const createRootReducer = history => (state, action) => { let currentState = state; if (action.type === resetActionTypes.RESET_STATE) { currentState = undefined; } - return appReducer(currentState, action); + return appReducer(history)(currentState, action); }; -export default rootReducer; +export default createRootReducer; diff --git a/src/ducks/modules/session.js b/src/ducks/modules/session.js index 1ba60fe903..42db6e416a 100644 --- a/src/ducks/modules/session.js +++ b/src/ducks/modules/session.js @@ -1,4 +1,4 @@ -import { push } from 'react-router-redux'; +import { push } from 'connected-react-router'; import { actionTypes as SessionsActionTypes, actionCreators as SessionsActions } from './sessions'; import { actionCreators as SessionWorkerActions } from './sessionWorkers'; import { actionTypes as installedProtocolsActionTypes } from './installedProtocols'; diff --git a/src/ducks/store.js b/src/ducks/store.js index 735073716d..45320d328c 100644 --- a/src/ducks/store.js +++ b/src/ducks/store.js @@ -2,12 +2,12 @@ import { createStore, applyMiddleware, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import thunk from 'redux-thunk'; -import { routerMiddleware } from 'react-router-redux'; +import { routerMiddleware } from 'connected-react-router'; import { createHashHistory as createHistory } from 'history'; import { getEnv } from '../utils/Environment'; import logger from './middleware/logger'; import epics from './middleware/epics'; -import rootReducer from './modules/rootReducer'; +import createRootReducer from './modules/rootReducer'; const persistConfig = { key: 'networkCanvas6', @@ -29,10 +29,10 @@ export const history = createHistory(); const getReducer = () => { if (env.REACT_APP_NO_PERSIST) { - return rootReducer; + return createRootReducer(history); } - return persistReducer(persistConfig, rootReducer); + return persistReducer(persistConfig, createRootReducer(history)); }; export const store = createStore( diff --git a/src/index.js b/src/index.js index 450385a975..1c1d836da2 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,7 @@ import ReactDOM from 'react-dom'; import { PersistGate } from 'redux-persist/integration/react'; import { ready as secureCommsReady } from 'secure-comms-api/cipher'; import { Provider } from 'react-redux'; -import { ConnectedRouter, push } from 'react-router-redux'; +import { ConnectedRouter, push } from 'connected-react-router'; import { Spinner } from '@codaco/ui'; import initFileOpener from './utils/initFileOpener'; import initMenuActions from './utils/initMenuActions'; diff --git a/src/routes.js b/src/routes.js index abde959e0d..757d389748 100644 --- a/src/routes.js +++ b/src/routes.js @@ -22,13 +22,16 @@ function mapStateToProps(state) { // If there is an activeSessionId, redirect to it let SetupRequiredRoute = ( { component: Component, sessionId, ...rest }, -) => ( - sessionId ? ( - - ) : ( - - ) -); +) => { + console.log('setuprequiredroute', sessionId); + return ( + sessionId ? ( + + ) : ( + + ) + ); +}; SetupRequiredRoute.propTypes = { @@ -38,14 +41,34 @@ SetupRequiredRoute.propTypes = { SetupRequiredRoute = connect(mapStateToProps)(SetupRequiredRoute); -export default () => ( - - - - - - - - -); +const Test = ({ + activeSessionId, +}) => { + console.log('its alive', activeSessionId); + return ( + + + + + + + + + ); +}; + +const Test2 = connect(mapStateToProps)(Test); + +export default Test2; + +// export default () => ( +// +// +// +// +// +// +// +// +// ); diff --git a/src/styles/components/_all.scss b/src/styles/components/_all.scss index cd5ff2a5e5..e8e36fe65b 100644 --- a/src/styles/components/_all.scss +++ b/src/styles/components/_all.scss @@ -15,6 +15,7 @@ @import 'pairing-code-input'; @import 'panels'; @import 'panel'; +@import 'protocol'; @import 'stage'; @import 'drop-zone'; @import 'preset-switcher'; diff --git a/src/styles/components/_protocol.scss b/src/styles/components/_protocol.scss new file mode 100644 index 0000000000..61db2d5e26 --- /dev/null +++ b/src/styles/components/_protocol.scss @@ -0,0 +1,11 @@ +.protocol { + display: flex; + width: 100vw; + height: 100%; + + &__content { + background-color: palette('background'); + flex: 1 1 auto; + height: 100%; + } +} diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 94b167e7f0..4f3dfeaed8 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -111,7 +111,6 @@ > .protocol-card { max-width: 35rem; - margin: 0 auto; } } From 42e43d542556a7465aad6b9f87bc0c711b834ac9 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 26 Aug 2020 00:12:34 +0100 Subject: [PATCH 30/71] remove use of useinvertedscale --- .../SessionPanel/SessionInformation.js | 8 ++- .../SessionPanel/SessionNavigation.js | 8 +-- src/components/SessionPanel/SessionPanel.js | 6 +- src/components/SessionPanel/SubMenu.js | 10 ++-- src/components/StagesMenu/StagesMenu.js | 10 ++-- src/containers/StartScreen/StartScreen.js | 2 - src/routes.js | 58 ++++++------------- 7 files changed, 35 insertions(+), 67 deletions(-) diff --git a/src/components/SessionPanel/SessionInformation.js b/src/components/SessionPanel/SessionInformation.js index 67eb995114..af92361680 100644 --- a/src/components/SessionPanel/SessionInformation.js +++ b/src/components/SessionPanel/SessionInformation.js @@ -96,9 +96,11 @@ const SessionInformation = (props) => { newCaseID: caseId, }} > - +
    + +
    diff --git a/src/components/SessionPanel/SessionNavigation.js b/src/components/SessionPanel/SessionNavigation.js index 4e115a8c58..409f4f4b1c 100644 --- a/src/components/SessionPanel/SessionNavigation.js +++ b/src/components/SessionPanel/SessionNavigation.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Icon, ProgressBar } from '@codaco/ui'; -import { motion, useInvertedScale } from 'framer-motion'; +import { motion } from 'framer-motion'; const SessionNavigation = (props) => { const { @@ -9,12 +9,9 @@ const SessionNavigation = (props) => { onClickNext, percentProgress, setExpanded, - setShowSubMenu, } = props; - const { scaleX, scaleY } = useInvertedScale(); - const variants = { normal: { opacity: 1, @@ -32,8 +29,7 @@ const SessionNavigation = (props) => { exit="expanded" animate="normal" className="session-navigation" - useInvertedScale - style={{ scaleX, scaleY }} + layout > { : ; return ( - + { expanded && ( setExpanded(false)} className="close-button-wrapper" />)} @@ -71,7 +71,7 @@ const SessionPanel = React.forwardRef((props, ref) => { /> ) } - +
    ); }); diff --git a/src/components/SessionPanel/SubMenu.js b/src/components/SessionPanel/SubMenu.js index b005229362..7187eef11c 100644 --- a/src/components/SessionPanel/SubMenu.js +++ b/src/components/SessionPanel/SubMenu.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Icon } from '@codaco/ui'; import { connect } from 'react-redux'; -import { motion, useInvertedScale } from 'framer-motion'; +import { motion } from 'framer-motion'; import { compose } from 'recompose'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; @@ -15,8 +15,6 @@ const SubMenu = (props) => { endSession, } = props; - const { scaleX, scaleY } = useInvertedScale(); - const variants = { show: { opacity: 1, @@ -51,10 +49,10 @@ const SubMenu = (props) => { className="sub-menu" animate="show" initial="hide" - style={{ scaleX, scaleY }} + layout key="sub-menu" > -
    +

    Menu

    { Exit Interview
    -
    + ); diff --git a/src/components/StagesMenu/StagesMenu.js b/src/components/StagesMenu/StagesMenu.js index bb70259c70..402076d621 100644 --- a/src/components/StagesMenu/StagesMenu.js +++ b/src/components/StagesMenu/StagesMenu.js @@ -2,7 +2,7 @@ import React, { useState, useRef, useLayoutEffect } from 'react'; import PropTypes from 'prop-types'; import { Text } from '@codaco/ui/lib/components/Fields'; import { connect } from 'react-redux'; -import { motion, useInvertedScale, AnimatePresence } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { compose } from 'recompose'; import { getCSSVariableAsNumber, getCSSVariableAsString } from '@codaco/ui/lib/utils/CSSVariables'; import { getProtocolStages } from '../../selectors/protocol'; @@ -20,7 +20,6 @@ const StagesMenu = (props) => { const [filter, setFilter] = useState(''); const [imageLoaded, updateImageLoaded] = useState(false); - const { scaleX, scaleY } = useInvertedScale(); const scrollerRef = useRef(null); const baseAnimationDuration = getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; @@ -50,7 +49,7 @@ const StagesMenu = (props) => { * that are currently visible. * */ - const delayScale = 0.075; + const delayScale = 0.1; // Active index 0, current index less than 8: animate first 8 items. if ( @@ -88,7 +87,7 @@ const StagesMenu = (props) => { x: 0, opacity: 1, transition: { - delay: calculateDelay(currentItemIndex), + delay: calculateDelay(currentItemIndex) + 0.25, duration: baseAnimationDuration, easing: baseAnimationEasing, }, @@ -133,7 +132,6 @@ const StagesMenu = (props) => { animate="expanded" exit="filtered" key={item.id} - layout className="stages-menu__preview-wrapper" > { variants={variants} initial="normal" animate="expanded" - style={{ scaleX, scaleY }} + layout >
    {renderMenuItems.length > 0 ? ( diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 61015c0b94..3b28a21a01 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -28,10 +28,8 @@ const StartScreen = ({ }; if (activeSessionId) { - console.log('a', activeSessionId, sessions); const stageIndex = sessions[activeSessionId].stageIndex; const pathname = `/session/${activeSessionId}/${stageIndex}`; - console.log('active', activeSessionId, stageIndex, pathname); return (); } diff --git a/src/routes.js b/src/routes.js index 757d389748..d822f17e9c 100644 --- a/src/routes.js +++ b/src/routes.js @@ -22,17 +22,13 @@ function mapStateToProps(state) { // If there is an activeSessionId, redirect to it let SetupRequiredRoute = ( { component: Component, sessionId, ...rest }, -) => { - console.log('setuprequiredroute', sessionId); - return ( - sessionId ? ( - - ) : ( - - ) - ); -}; - +) => ( + sessionId ? ( + + ) : ( + + ) +); SetupRequiredRoute.propTypes = { component: PropTypes.func.isRequired, @@ -41,34 +37,14 @@ SetupRequiredRoute.propTypes = { SetupRequiredRoute = connect(mapStateToProps)(SetupRequiredRoute); -const Test = ({ - activeSessionId, -}) => { - console.log('its alive', activeSessionId); - return ( - - - - - - - - - ); -}; - -const Test2 = connect(mapStateToProps)(Test); - -export default Test2; - -// export default () => ( -// -// -// -// -// -// -// -// -// ); +export default () => ( + + + + + + + + +); From abbbd5deac80a0e57fb22974421f93ff50ac0ebc Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 27 Aug 2020 11:57:21 +0100 Subject: [PATCH 31/71] implement onlineStatus hook --- package-lock.json | 6 + public/components/assetProtocol.js | 4 - src/components/Cards/ProtocolCard.js | 2 +- src/components/NewFilterableListWrapper.js | 9 +- .../SessionPanel/SessionInformation.js | 11 +- src/containers/App.js | 150 +++++++++--------- src/containers/Server/DiscoveredServerList.js | 69 ++++++-- src/containers/StartScreen/ImportSection.js | 2 + .../StartScreen/InterviewSection.js | 27 ++-- .../StartScreen/NewSessionOverlay.js | 116 ++++++-------- .../StartScreen/ResumeSessionPicker.js | 47 +++--- src/containers/StartScreen/ServerSection.js | 7 +- .../StartScreen/StartInterviewPicker.js | 107 +++++++++++++ src/ducks/modules/installedProtocols.js | 13 +- src/hooks/useInterval.js | 25 +++ src/hooks/useOnlineStatus.js | 29 ++++ src/selectors/session.js | 4 +- .../components/_new-filterable-list.scss | 16 +- .../stages-menu/_stage-preview.scss | 4 - src/styles/containers/StartScreen/_all.scss | 29 ++-- src/utils/Validations.js | 2 +- src/utils/protocol/extractProtocol.js | 1 - 22 files changed, 457 insertions(+), 223 deletions(-) create mode 100644 src/containers/StartScreen/StartInterviewPicker.js create mode 100644 src/hooks/useInterval.js create mode 100644 src/hooks/useOnlineStatus.js diff --git a/package-lock.json b/package-lock.json index e8e88a3ffa..36604fd837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2524,6 +2524,12 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@rehooks/online-status": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rehooks/online-status/-/online-status-1.1.2.tgz", + "integrity": "sha512-tuz6RtdgqkBMZr82UqTkzPCsHNx6tpEyhTZ5OD8WhDpU4/TLzljBCseuWLlbeQiygbM9F2A4Ea7P+wyjohJXBw==", + "dev": true + }, "@scarf/scarf": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.1.0.tgz", diff --git a/public/components/assetProtocol.js b/public/components/assetProtocol.js index 4f9cafd6eb..e4dd3a31ec 100644 --- a/public/components/assetProtocol.js +++ b/public/components/assetProtocol.js @@ -18,10 +18,6 @@ const registerProtocol = () => const filePath = err ? userDataFilePath : appFilePath; callback({ path: filePath }); }); - }, (error) => { - if (error) { - console.error('Failed to register protocol'); - } }); exports.registerProtocol = registerProtocol; diff --git a/src/components/Cards/ProtocolCard.js b/src/components/Cards/ProtocolCard.js index 6c27e9e3ea..efdc8ba39e 100644 --- a/src/components/Cards/ProtocolCard.js +++ b/src/components/Cards/ProtocolCard.js @@ -123,7 +123,7 @@ ProtocolCard.propTypes = { attributes: PropTypes.shape({ schemaVersion: PropTypes.number.isRequired, lastModified: PropTypes.string.isRequired, - installationDate: PropTypes.string, + installationDate: PropTypes.number.isRequired, name: PropTypes.string.isRequired, description: PropTypes.string, }).isRequired, diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index bfa76933a4..cd955982c6 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -50,8 +50,9 @@ const NewFilterableListWrapper = (props) => { : Object.values(item); // Include in filtered list if any of the attribute property values // include the filter value + console.log('some', item); return itemAttributes.some( - property => property.toString().toLowerCase().includes(normalizedFilterTerm), + property => property && property.toString().toLowerCase().includes(normalizedFilterTerm), ); }, ); @@ -90,6 +91,8 @@ const NewFilterableListWrapper = (props) => { }, }; + const sortedAndFilteredList = getFilteredAndSortedItemList(); + return (
    { variants={containerVariants} initial="hidden" animate="visible" - className="test" + className="filterable-list-scroller" > { - getFilteredAndSortedItemList().map(item => ( + sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( timestamp && new Date(Date.now() - timestamp).toISOString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1'); @@ -57,6 +58,14 @@ const SessionInformation = (props) => { }); }; + const [interviewDuration, setInterviewDuration] = useState(sessionProgress.startedAt ? elapsedTime(sessionProgress.startedAt) : 'Unknown'); + + if (sessionProgress.startedAt) { + useInterval(() => { + setInterviewDuration(elapsedTime(sessionProgress.startedAt)); + }, 1000); + } + const [showCaseIDRename, setShowCaseIDRename] = useState(false); const handleChangeCaseID = ({ newCaseID }) => { @@ -106,7 +115,7 @@ const SessionInformation = (props) => {

    Interview Duration

    -

    {sessionProgress.startedAt ? elapsedTime(sessionProgress.startedAt) : 'Unknown'}

    +

    {interviewDuration}

    Case ID

    diff --git a/src/containers/App.js b/src/containers/App.js index b1da64b016..32b7b718a7 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { useEffect } from 'react'; import { motion } from 'framer-motion'; import PropTypes from 'prop-types'; import { compose } from 'redux'; @@ -12,107 +12,111 @@ import { isElectron, isCordova, isWindows, isMacOS, isLinux, isPreview, getEnv, import DialogManager from '../components/DialogManager'; import { SettingsMenu } from '../components/SettingsMenu'; +const getElectronWindow = () => { + if (isElectron()) { + const electron = window.require('electron'); // eslint-disable-line global-require + return electron.remote.getCurrentWindow(); + } + return false; +}; + /** * Main app container. * @param props {object} - children */ -class App extends PureComponent { - componentDidMount() { - this.setFontSize(); +const App = ({ + startFullScreen, + setStartFullScreen, + interfaceScale, + useDynamicScaling, + children, +}) => { + const win = getElectronWindow(); + const env = getEnv(); + + const setFontSize = () => { + const root = document.documentElement; + const newFontSize = useDynamicScaling ? + `${(1.65 * interfaceScale) / 100}vmin` : + `${(16 * interfaceScale) / 100}px`; + + root.style.setProperty('--base-font-size', newFontSize); + }; + useEffect(() => { if (isCordova()) { // Enable viewport shrinking on iOS to mirror behaviour on android. window.Keyboard.shrinkView(true); } - const env = getEnv(); - if (!env.REACT_APP_NO_FULLSCREEN) { // Spy on window fullscreen status if (isElectron() && !isPreview()) { - const win = this.getElectronWindow(); - win.setFullScreen(!!this.props.startFullScreen); + win.setFullScreen(!!startFullScreen); win.on('enter-full-screen', () => { - this.props.setStartFullScreen(true); + setStartFullScreen(true); }); win.on('leave-full-screen', () => { - this.props.setStartFullScreen(false); + setStartFullScreen(false); }); } } - } - - componentDidUpdate() { - this.setFontSize(); - } - - setFontSize = () => { - const root = document.documentElement; - const newFontSize = this.props.useDynamicScaling ? - `${(1.65 * this.props.interfaceScale) / 100}vmin` : - `${(16 * this.props.interfaceScale) / 100}px`; - root.style.setProperty('--base-font-size', newFontSize); - } - - getElectronWindow = () => { - if (isElectron()) { - const electron = window.require('electron'); // eslint-disable-line global-require - return electron.remote.getCurrentWindow(); - } - return false; - } + return () => { + if (win) { + win.removeAllListeners(); + } + }; + }, []); - render() { - const { children } = this.props; + setFontSize(); - const list = { - visible: { - opacity: 1, - transition: { - when: 'beforeChildren', - }, + const list = { + visible: { + opacity: 1, + transition: { + when: 'beforeChildren', }, - hidden: { - opacity: 0, - }, - }; + }, + hidden: { + opacity: 0, + }, + }; - return ( - +
    +
    -
    -
    - - { children } -
    - - - ); - } -} + + { children } +
    + + + ); +}; App.propTypes = { children: PropTypes.any, diff --git a/src/containers/Server/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js index 8d73e2088f..edb97b688f 100644 --- a/src/containers/Server/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; +import { connect } from 'react-redux'; import { AnimatePresence, motion } from 'framer-motion'; -import { Icon, Spinner, Scroller } from '@codaco/ui'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; +import { Icon, Spinner, Scroller, Button } from '@codaco/ui'; import { ServerCard as UIServerCard } from '@codaco/ui/lib/components/Cards'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import ServerDiscoverer from '../../utils/serverDiscoverer'; - const ServerCard = (props) => { const { name, @@ -32,11 +34,15 @@ const ServerCard = (props) => { /** * Displays a list of available servers discovered via MDNS. */ -const DiscoveredServerList = () => { +const DiscoveredServerList = ({ + openDialog, +}) => { + const onlineStatus = useOnlineStatus(); const [serverList, updateServerList] = useState([]); const [error, setError] = useState(null); useEffect(() => { + console.log('server discoverer use effect', onlineStatus); const serverDiscoverer = new ServerDiscoverer(); // Bind events @@ -72,20 +78,55 @@ const DiscoveredServerList = () => { return () => { serverDiscoverer.removeAllListeners(); }; - }, []); + }, [onlineStatus]); + + const showErrorDialog = () => { + const errorObject = new Error(error); + errorObject.friendlyMessage = 'The automatic Server discovery feature could not be used. Consult the error message below for further information. Contact the Network Canvas project team for help with this error.'; + openDialog({ + type: 'Error', + title: 'Automatic Server discovery unavailable', + error: errorObject, + confirmLabel: 'Okay', + }); + }; + if (!onlineStatus) { + return ( +
    +
    +
    + +
    +
    +

    You don't seem to have a network connection.

    +

    + Pairing with Server, fetching protocols from Server, and uploading data, all + require an active network connection. Check your device is connected to your + network, and try again. +

    +
    +
    +
    + ); + } if (error) { - console.log('error', error); return ( -
    -
    +
    +
    -

    Automatic server discovery unavailable

    -

    {error}

    +

    We couldn't enable automatic discovery.

    +

    + There was a problem enabling the automatic Server discovery + feature of Network Canvas. You can still pair with your computer + running Server by entering manual connection details using the + button below. +

    +
    @@ -129,4 +170,12 @@ DiscoveredServerList.defaultProps = { DiscoveredServerList.propTypes = { }; -export default DiscoveredServerList; +const mapDispatchToProps = { + openDialog: dialogActions.openDialog, +}; + +const mapStateToProps = state => ({ +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DiscoveredServerList); + diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index a8bd2e7c31..3ac5e9fda1 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -5,6 +5,7 @@ import { GraphicButton, Button } from '@codaco/ui'; import { Section } from '.'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; import ProtocolUrlForm from './ProtocolUrlForm'; +import importLocalProtocol from '../../utils/protocol/importLocalProtocol'; const ImportSection = (props) => { const { @@ -33,6 +34,7 @@ const ImportSection = (props) => {

    Import

    From File

    diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 4f95ca6e91..ccf0e4a43d 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -9,6 +9,7 @@ import { getLastActiveSession } from '../../selectors/session'; import NewSessionOverlay from './NewSessionOverlay'; import StackButton from '../../components/StackButton'; import ResumeSessionPicker from './ResumeSessionPicker'; +import StartInterviewPicker from './StartInterviewPicker'; const InterviewSection = (props) => { const { @@ -17,7 +18,8 @@ const InterviewSection = (props) => { lastActiveSession, addSession, showResumeSessionPicker, - toggleShowResumeSessionPicker, + showStartInterviewPicker, + toggleUIOverlay, } = props; const lastActiveProtocol = { @@ -59,7 +61,7 @@ const InterviewSection = (props) => {
    -

    Start an Interview

    +

    Start a New Interview

    { Object.keys(installedProtocols).length > 1 && (
    toggleUIOverlay('showStartInterviewPicker')} >

    { - (Object.keys(installedProtocols).length - 1) > 1 ? `+${Object.keys(installedProtocols).length - 1} Protocols` : `+${Object.keys(installedProtocols).length - 1} Protocol` + (Object.keys(installedProtocols).length - 1) > 1 ? `+${Object.keys(installedProtocols).length - 1} Other Protocols` : `+${Object.keys(installedProtocols).length - 1} Other Protocol` }

    @@ -93,6 +96,10 @@ const InterviewSection = (props) => { }
    + toggleUIOverlay('showStartInterviewPicker')} + /> {
    -

    Resume last Interview

    +

    Resume an Interview

    { label="Resume other interview" cardColor="var(--color-platinum)" insetColor="var(--color-platinum--dark)" - clickHandler={toggleShowResumeSessionPicker} + clickHandler={() => toggleUIOverlay('showResumeSessionPicker')} >

    { - (Object.keys(sessions).length - 1) > 1 ? `+${Object.keys(sessions).length - 1} Interviews` : `+${Object.keys(sessions).length - 1} Interview` + (Object.keys(sessions).length - 1) > 1 ? `+${Object.keys(sessions).length - 1} Other Interviews` : `+${Object.keys(sessions).length - 1} Other Interview` }

    @@ -135,7 +142,7 @@ const InterviewSection = (props) => { toggleUIOverlay('showResumeSessionPicker')} /> )} @@ -154,8 +161,8 @@ function mapStateToProps(state) { return { installedProtocols: state.installedProtocols, showProtocolUrlForm: state.ui.showProtocolUrlForm, - showNewSessionOverlay: state.ui.showNewSessionOverlay, showResumeSessionPicker: state.ui.showResumeSessionPicker, + showStartInterviewPicker: state.ui.showStartInterviewPicker, sessions: state.sessions, lastActiveSession: getLastActiveSession(state), }; @@ -164,7 +171,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { addSession: (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)), - toggleShowResumeSessionPicker: () => dispatch(uiActions.toggle('showResumeSessionPicker')), + toggleUIOverlay: overlay => dispatch(uiActions.toggle(overlay)), }; } diff --git a/src/containers/StartScreen/NewSessionOverlay.js b/src/containers/StartScreen/NewSessionOverlay.js index 47b5d46e22..29227bb092 100644 --- a/src/containers/StartScreen/NewSessionOverlay.js +++ b/src/containers/StartScreen/NewSessionOverlay.js @@ -1,75 +1,63 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { Button } from '@codaco/ui'; import Overlay from '../../containers/Overlay'; import { Form } from '../../containers'; +import { required, maxLength } from '../../utils/Validations'; -class NewSessionOverlay extends Component { - constructor(props) { - super(props); +const NewSessionOverlay = ({ + handleSubmit, + show, + onClose, +}) => { + const formConfig = { + formName: 'case-id-form', + fields: [ + { + label: null, + name: 'case_id', + component: 'Text', + placeholder: 'Enter a unique case ID', + validate: [required('You must enter a case ID before you can continue.'), maxLength(30)], + }, + ], + }; - this.state = { - caseID: '', - }; + const onSubmitForm = (fields) => { + handleSubmit(fields.case_id); + }; - this.overlay = React.createRef(); - } - - onSubmitForm = (fields) => { - this.props.handleSubmit(fields.case_id); - } - - render() { - const { show, onClose } = this.props; - - const formConfig = { - formName: 'case-id-form', - fields: [ - { - label: null, - name: 'case_id', - component: 'Text', - placeholder: 'Enter a unique case ID', - validation: { - required: true, - maxLength: 30, - }, - }, - ], - }; - - return ( - -
    -

    - Before the interview begins, enter a case ID. - This will be shown on the resume interview screen to help you quickly - identify this session. -

    -
    -
    - -
    -
    -
    -
    - ); - } -} + return ( + +
    +

    + Before the interview begins, enter a case ID. + This will be shown on the resume interview screen to help you quickly + identify this session. +

    +
    +
    + +
    +
    +
    +
    + ); +}; NewSessionOverlay.propTypes = { show: PropTypes.bool, diff --git a/src/containers/StartScreen/ResumeSessionPicker.js b/src/containers/StartScreen/ResumeSessionPicker.js index 61eb0e53cb..0029fc5b3c 100644 --- a/src/containers/StartScreen/ResumeSessionPicker.js +++ b/src/containers/StartScreen/ResumeSessionPicker.js @@ -2,35 +2,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { isEmpty, get } from 'lodash'; +import { get } from 'lodash'; import { SessionCard } from '@codaco/ui/lib/components/Cards'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { actionCreators as sessionActions } from '../../ducks/modules/session'; import { NewFilterableListWrapper } from '../../components'; - import { Overlay } from '../Overlay'; -import { entityAttributesProperty } from '../../ducks/modules/network'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; -const emptyView = ( -
    -
    -

    No previous interviews found

    -

    - You have no in-progress interview sessions available to resume. - To begin a new session, select a protocol from the main start screen. -

    -
    -
    -); - -const ResumeSessionPicker = ({ show, onClose, sessions, installedProtocols }) => { - if (isEmpty(sessions)) { - return emptyView; - } +const ResumeSessionPicker = ({ + show, + onClose, + sessions, + setSession, + installedProtocols, +}) => { + const handleSessionCardClick = (sessionUUID) => { + setSession(sessionUUID); + onClose(); + }; - const newSessions = [...Object.keys(sessions)].map((sessionUUID) => { + const formattedSessions = [...Object.keys(sessions)].map((sessionUUID) => { const session = sessions[sessionUUID]; const protocol = get(installedProtocols, [session.protocolUID]); @@ -44,7 +36,7 @@ const ResumeSessionPicker = ({ show, onClose, sessions, installedProtocols }) => updatedAt: session.updatedAt, protocolName: protocol.name, progress, - onClickHandler: () => console.log('cleek'), + onClickHandler: () => handleSessionCardClick(sessionUUID), }; }); @@ -56,7 +48,7 @@ const ResumeSessionPicker = ({ show, onClose, sessions, installedProtocols }) => > ({ + setSession: bindActionCreators(sessionActions.setSession, dispatch), +}); export default connect(mapStateToProps, mapDispatchToProps)(ResumeSessionPicker); diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 1f066448e7..681e33e4df 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -6,6 +6,7 @@ import { Section } from '.'; import DiscoveredServerList from '../Server/DiscoveredServerList'; import ServerAddressForm from './ServerAddressForm'; import { ExternalLink } from '../../components'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; const ServerSection = (props) => { const { @@ -14,6 +15,8 @@ const ServerSection = (props) => { pairedServer, } = props; + const onlineStatus = useOnlineStatus(); + return (
    @@ -36,10 +39,10 @@ const ServerSection = (props) => {
    { !pairedServer ? ( - + ) : [ , - , + , ] }
    diff --git a/src/containers/StartScreen/StartInterviewPicker.js b/src/containers/StartScreen/StartInterviewPicker.js new file mode 100644 index 0000000000..804a63bd31 --- /dev/null +++ b/src/containers/StartScreen/StartInterviewPicker.js @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { ProtocolCard } from '@codaco/ui/lib/components/Cards'; +import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; +import { NewFilterableListWrapper } from '../../components'; +import NewSessionOverlay from './NewSessionOverlay'; +import { Overlay } from '../Overlay'; + +const StartInterviewPicker = ({ + show, + onClose, + addSession, + installedProtocols, +}) => { + const [showNewSessionOverlay, setShowNewSessionOverlay] = useState(false); + const [selectedProtocol, setSelectedProtocol] = useState(null); + + const handleCloseNewSessionOverlay = () => { + setShowNewSessionOverlay(false); + setSelectedProtocol(null); + }; + + const handleCreateSession = (caseId) => { + addSession(caseId, selectedProtocol); + handleCloseNewSessionOverlay(); + onClose(); + return true; + }; + + const handleProtocolCardClick = (protocolUID) => { + console.log('handleprotocolcardclick', protocolUID); + setShowNewSessionOverlay(true); + setSelectedProtocol(protocolUID); + }; + + const formattedProtocols = [...Object.keys(installedProtocols)].map((protocolUID) => { + const { + schemaVersion, + lastModified, + installationDate, + name, + description, + } = installedProtocols[protocolUID]; + + return { + schemaVersion, + lastModified, + installationDate, + name, + description, + onClickHandler: () => handleProtocolCardClick(protocolUID), + }; + }); + + return ( + + + + + ); +}; + +StartInterviewPicker.propTypes = { + installedProtocols: PropTypes.object.isRequired, + addSession: PropTypes.func.isRequired, +}; + +function mapStateToProps(state) { + return { + installedProtocols: state.installedProtocols, + }; +} + +const mapDispatchToProps = dispatch => ({ + addSession: (caseId, protocol) => dispatch(sessionsActions.addSession(caseId, protocol)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StartInterviewPicker); diff --git a/src/ducks/modules/installedProtocols.js b/src/ducks/modules/installedProtocols.js index ba8f7c92ce..376583065b 100644 --- a/src/ducks/modules/installedProtocols.js +++ b/src/ducks/modules/installedProtocols.js @@ -85,19 +85,26 @@ export default function reducer(state = initialState, action = {}) { case IMPORT_PROTOCOL_COMPLETE: { const newProtocol = action.protocolData; - // If the protocol name (which is the true UID of protocol) already exists, overwrite. + // If the protocol name (which is the true UID of protocol) already exists, + // overwrite. We only get here after user has confirmed. const existingIndex = findKey(state, protocol => protocol.name === newProtocol.name); if (existingIndex) { return { ...state, - [existingIndex]: omit(newProtocol, 'uid'), + [existingIndex]: { + ...omit(newProtocol, 'uid'), + installationDate: Date.now(), + }, }; } return { ...state, - [newProtocol.uid]: omit(newProtocol, 'uid'), + [newProtocol.uid]: { + ...omit(newProtocol, 'uid'), + installationDate: Date.now(), + }, }; } default: diff --git a/src/hooks/useInterval.js b/src/hooks/useInterval.js new file mode 100644 index 0000000000..9a0212dc9d --- /dev/null +++ b/src/hooks/useInterval.js @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +const useInterval = (callback, delay) => { + const savedCallback = useRef(); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + + return true; + }, [delay]); +}; + +export default useInterval; diff --git a/src/hooks/useOnlineStatus.js b/src/hooks/useOnlineStatus.js new file mode 100644 index 0000000000..0e2a82e9cd --- /dev/null +++ b/src/hooks/useOnlineStatus.js @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +function getOnlineStatus() { + return typeof navigator !== 'undefined' && + typeof navigator.onLine === 'boolean' + ? navigator.onLine + : true; +} + +const useOnlineStatus = () => { + const [onlineStatus, setOnlineStatus] = useState(getOnlineStatus()); + + useEffect(() => { + const goOnline = () => setOnlineStatus(true); + const goOffline = () => setOnlineStatus(false); + + window.addEventListener('online', goOnline); + window.addEventListener('offline', goOffline); + + return () => { + window.removeEventListener('online', goOnline); + window.removeEventListener('offline', goOffline); + }; + }, []); + + return onlineStatus; +}; + +export default useOnlineStatus; diff --git a/src/selectors/session.js b/src/selectors/session.js index ae142f25f0..5b721fdae9 100644 --- a/src/selectors/session.js +++ b/src/selectors/session.js @@ -80,7 +80,7 @@ export const getSessionProgress = (state) => { const isLastStage = currentStage === stageCount - 1; // includes finish screen if present const isLastScreen = currentStage === screenCount - 1; - const createdAt = session.createdAt; + const startedAt = session.startedAt; const lastExportedAt = session.lastExportedAt; return { @@ -97,7 +97,7 @@ export const getSessionProgress = (state) => { stageProgress, promptProgress, percentProgress, - createdAt, + startedAt, lastExportedAt, }; }; diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index db86392646..6da1caeaec 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -73,13 +73,25 @@ flex-direction: column; height: 60vh; - >.test { + .filterable-list-scroller { height: 100%; overflow-y: auto; padding: unit(2) 0; display: grid; grid-template-columns: 1fr 1fr; - } + &:empty { + display: flex; + + &::after { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + content: 'No items to display.'; + } + } + } } } diff --git a/src/styles/components/stages-menu/_stage-preview.scss b/src/styles/components/stages-menu/_stage-preview.scss index c06396182f..71f89315ce 100644 --- a/src/styles/components/stages-menu/_stage-preview.scss +++ b/src/styles/components/stages-menu/_stage-preview.scss @@ -51,10 +51,6 @@ right: 0; background: var(--modal-overlay); z-index: var(--z-background); - @supports (backdrop-filter: blur(0.75rem)) { - background: var(--modal-overlay); - backdrop-filter: blur(0.75rem); - } padding: unit(4); text-align: right; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 4f3dfeaed8..bdb133eb4a 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -118,7 +118,6 @@ flex: 0 1 40%; display: flex; flex-direction: column; - margin-top: 1.8rem; .stack-button { h4 { @@ -140,10 +139,10 @@ &__last-session { padding: unit(4) unit(6); flex: 1 1 auto; + width: 60%; > .session-card { max-width: 35rem; - margin: 0 auto; } } @@ -151,7 +150,6 @@ flex: 0 1 40%; display: flex; flex-direction: column; - margin-top: 1.8rem; .stack-button { h4 { @@ -354,22 +352,25 @@ } .discovered-server-list { - background: var(--color-slate-blue--dark); - padding: 1.2rem; - border-radius: 0.75rem; - height: 22rem; + background: #ccc; + padding: unit(2) unit(4); + border-radius: var(--border-radius); + max-height: 30rem; display: flex; - justify-content: center; - align-items: center; + + &--error { + + + } .server-list { - &--error { - background: tomato; - padding: unit(2); - display: flex; + display: flex; + + &--error, + &--offline { .error__icon { - flex: 0 0 15rem; + flex: 0 0 10rem; display: flex; justify-content: center; align-items: center; diff --git a/src/utils/Validations.js b/src/utils/Validations.js index 69d145b4b0..ae673ee18d 100644 --- a/src/utils/Validations.js +++ b/src/utils/Validations.js @@ -58,7 +58,7 @@ export const maxSelected = max => (!value || coerceArray(value).length > max ? `You must choose a maximum of ${max} option(s)` : undefined); export default { - required, + required: () => required(), minLength, maxLength, minValue, diff --git a/src/utils/protocol/extractProtocol.js b/src/utils/protocol/extractProtocol.js index 0473306efb..12683a0107 100644 --- a/src/utils/protocol/extractProtocol.js +++ b/src/utils/protocol/extractProtocol.js @@ -56,7 +56,6 @@ const extractZipFile = inEnvironment((environment) => { return (zipObject, destination) => { const extractPath = path.join(destination, zipObject.name); - console.log('zipobject', zipObject); return writeStream(extractPath, zipObject.nodeStream()); }; } From 1717adb2c511463386bdd763537276431c10f768 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 27 Aug 2020 14:54:12 +0100 Subject: [PATCH 32/71] discovered server list implemented --- src/containers/Server/DiscoveredServerList.js | 81 +++++++++---------- src/styles/containers/StartScreen/_all.scss | 61 +++++++++++--- 2 files changed, 87 insertions(+), 55 deletions(-) diff --git a/src/containers/Server/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js index edb97b688f..c3f1e08945 100644 --- a/src/containers/Server/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -1,30 +1,34 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; import { AnimatePresence, motion } from 'framer-motion'; -import useOnlineStatus from '../../hooks/useOnlineStatus'; -import { Icon, Spinner, Scroller, Button } from '@codaco/ui'; import { ServerCard as UIServerCard } from '@codaco/ui/lib/components/Cards'; +import { Icon, Spinner, Scroller, Button } from '@codaco/ui'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import ServerDiscoverer from '../../utils/serverDiscoverer'; const ServerCard = (props) => { const { name, - address, + host, + addresses, + pairingServiceUrl, } = props; const handleServerCardClick = () => { - console.log('start pairing with ', address); + console.log('start pairing with ', pairingServiceUrl); }; return ( @@ -42,7 +46,9 @@ const DiscoveredServerList = ({ const [error, setError] = useState(null); useEffect(() => { - console.log('server discoverer use effect', onlineStatus); + // When online status changes, useEffect is called. Reset any previous errors + setError(null); + const serverDiscoverer = new ServerDiscoverer(); // Bind events @@ -52,16 +58,18 @@ const DiscoveredServerList = ({ serverDiscoverer.on('SERVER_ANNOUNCED', (response) => { if (!response.name) { return; } - - const servers = serverList.slice(); - // Detect if we already have a service with this name - const serverIndex = serverList.findIndex(server => response.name === server.name); - if (serverIndex === -1) { - servers.push(response); - } else { - servers[serverIndex] = response; - } - updateServerList(servers); + updateServerList((prevState) => { + // Detect if we already have a service with this name + const serverIndex = prevState.findIndex(server => response.name === server.name); + + if (serverIndex !== -1) { + const newArray = [...prevState]; + newArray[serverIndex] = response; + return newArray; + } + + return [...prevState, response]; + }); }); serverDiscoverer.on('SERVER_REMOVED', (response) => { @@ -69,7 +77,6 @@ const DiscoveredServerList = ({ }); serverDiscoverer.on('SERVER_ERROR', (serverError) => { - console.log('server_error', serverError); setError(serverError); }); @@ -133,33 +140,23 @@ const DiscoveredServerList = ({ ); } - console.log('serverlist', serverList); - if (serverList.length > 0) { - return ( -
    - - - { - serverList.map(server => ( - - )) - } - - -
    - ); - } - return (
    -
    -

    Listening for nearby Servers...

    - -
    +
    +

    Looking for nearby Servers...

    +
    + + + { + serverList.map(server => ( + + )) + } + +
    ); }; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index bdb133eb4a..e2e604e09b 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -342,8 +342,6 @@ } &__discover { - display: flex; - flex-direction: column; padding: unit(4) unit(6); > h4 { @@ -352,15 +350,55 @@ } .discovered-server-list { - background: #ccc; - padding: unit(2) unit(4); + background: var(--color-slate-blue); border-radius: var(--border-radius); max-height: 30rem; display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + &--offline, &--error { + background: #ccc; + } + + header { + width: 100%; + display: flex; + background: var(--color-slate-blue--dark); + color: var(--color-white); + align-items: center; + padding: unit(1) unit(2); + + .spinner--small { + --circle-size: 0.5rem; + margin-right: unit(2); + } + } + .scrollable { + display: flex; + flex-wrap: wrap; + padding: unit(2) 0; + + > * { + width: 50%; + } + &:empty { + display: flex; + + &::after { + width: 100%; + height: 10rem; + display: flex; + justify-content: center; + align-items: center; + color: var(--color-white); + content: 'No Servers found.'; + } + } } .server-list { @@ -368,6 +406,7 @@ &--error, &--offline { + padding: unit(2); .error__icon { flex: 0 0 10rem; @@ -504,16 +543,16 @@ .stack-of-cards { - width: 13rem; - height: 9rem; + width: 16rem; + height: 11rem; display: flex; align-items: flex-end; position: relative; .mock-card { position: absolute; - width: 12rem; - height: 8rem; + width: 15rem; + height: 10rem; border-radius: var(--border-radius); background: var(--color-slate-blue--dark); box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.3); @@ -522,11 +561,7 @@ .main { width: 100%; - height: 5rem; - } - - &--one { - + height: 7rem; } &--two { From 4a51b1d732a15e6d14ec94560f54e081079ec66c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 2 Sep 2020 11:17:55 +0100 Subject: [PATCH 33/71] update server status message --- src/components/Cards/ServerCard.js | 31 ++++ src/components/Cards/index.js | 1 + src/components/NewFilterableListWrapper.js | 1 - src/components/SettingsMenu/SettingsMenu.js | 11 +- src/containers/App.js | 2 +- src/containers/Server/DiscoveredServerList.js | 66 +++----- src/containers/Server/PairingCodeDialog.js | 8 +- src/containers/Server/ServerPairingOverlay.js | 146 ------------------ src/containers/StartScreen/HeaderSection.js | 9 +- src/containers/StartScreen/ImportSection.js | 77 +++++---- .../StartScreen/ServerAddressForm.js | 41 +++-- src/containers/StartScreen/ServerSection.js | 85 ++++++++-- src/hooks/useServerConnectionStatus.js | 62 ++++++++ src/images/main-background.svg | 2 +- .../settings-menu/_settings-menu.scss | 6 +- src/styles/containers/StartScreen/_all.scss | 91 ++++++++++- src/utils/ApiClient.js | 14 ++ 17 files changed, 385 insertions(+), 268 deletions(-) create mode 100644 src/components/Cards/ServerCard.js delete mode 100644 src/containers/Server/ServerPairingOverlay.js create mode 100644 src/hooks/useServerConnectionStatus.js diff --git a/src/components/Cards/ServerCard.js b/src/components/Cards/ServerCard.js new file mode 100644 index 0000000000..1a40c1bde1 --- /dev/null +++ b/src/components/Cards/ServerCard.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { ServerCard as UIServerCard } from '@codaco/ui/lib/components/Cards'; + +const ServerCard = (props) => { + const { + name, + host, + addresses, + handleServerCardClick, + disabled, + } = props; + + return ( + + + + ); +}; + +export default ServerCard; diff --git a/src/components/Cards/index.js b/src/components/Cards/index.js index 055f3c7da8..6c940d91f2 100644 --- a/src/components/Cards/index.js +++ b/src/components/Cards/index.js @@ -1,2 +1,3 @@ export { default as ProtocolCard } from './ProtocolCard'; export { default as SessionCard } from './SessionCard'; +export { default as ServerCard } from './ServerCard'; diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index cd955982c6..6a305262a8 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -50,7 +50,6 @@ const NewFilterableListWrapper = (props) => { : Object.values(item); // Include in filtered list if any of the attribute property values // include the filter value - console.log('some', item); return itemAttributes.some( property => property && property.toString().toLowerCase().includes(normalizedFilterTerm), ); diff --git a/src/components/SettingsMenu/SettingsMenu.js b/src/components/SettingsMenu/SettingsMenu.js index 2130d5dc36..47a2ee92c7 100644 --- a/src/components/SettingsMenu/SettingsMenu.js +++ b/src/components/SettingsMenu/SettingsMenu.js @@ -36,7 +36,7 @@ const SettingsMenu = (props) => { opacity: 1, transition: { when: 'beforeChildren', - staggerChildren: 0.07, + staggerChildren: 0.1, duration: getAnimationDuration('--animation-duration-very-fast-ms'), easing: baseAnimationEasing, }, @@ -56,7 +56,7 @@ const SettingsMenu = (props) => { x: '0%', transition: { when: 'beforeChildren', - staggerChildren: 0.2, + staggerChildren: 0.1, duration: getAnimationDuration('--animation-duration-standard-ms'), easing: baseAnimationEasing, }, @@ -75,14 +75,15 @@ const SettingsMenu = (props) => { const navVariants = { show: { - x: '0%', + y: '0%', opacity: 1, transition: { - type: 'spring', + duration: getAnimationDuration('--animation-duration-very-fast-ms'), + easing: baseAnimationEasing, }, }, hide: { - x: '-10%', + y: '-10%', opacity: 0, transition: { duration: getAnimationDuration('--animation-duration-very-fast-ms'), diff --git a/src/containers/App.js b/src/containers/App.js index 32b7b718a7..5811793229 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -69,7 +69,7 @@ const App = ({ win.removeAllListeners(); } }; - }, []); + }, [win]); setFontSize(); diff --git a/src/containers/Server/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js index c3f1e08945..510d84b36f 100644 --- a/src/containers/Server/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -1,39 +1,12 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; -import { AnimatePresence, motion } from 'framer-motion'; -import { ServerCard as UIServerCard } from '@codaco/ui/lib/components/Cards'; +import { AnimatePresence } from 'framer-motion'; import { Icon, Spinner, Scroller, Button } from '@codaco/ui'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import ServerDiscoverer from '../../utils/serverDiscoverer'; - -const ServerCard = (props) => { - const { - name, - host, - addresses, - pairingServiceUrl, - } = props; - - const handleServerCardClick = () => { - console.log('start pairing with ', pairingServiceUrl); - }; - - return ( - - - - ); -}; +import { ServerCard } from '../../components/Cards'; +import { ServerAddressForm } from '../StartScreen/ServerAddressForm'; /** * Displays a list of available servers discovered via MDNS. @@ -42,12 +15,20 @@ const DiscoveredServerList = ({ openDialog, }) => { const onlineStatus = useOnlineStatus(); - const [serverList, updateServerList] = useState([]); + + const [availableServers, updateAvailableServers] = useState([]); + const [selectedServer, setSelectedServer] = useState(null); const [error, setError] = useState(null); + const [showServerAddressForm, setShowServerAddressForm] = useState(false); + + const handlePairingCardClick = (server) => { + setSelectedServer(server); + setShowServerAddressForm(true); + }; + useEffect(() => { - // When online status changes, useEffect is called. Reset any previous errors - setError(null); + setError(null); // Reset existing errors when onlineStatus changes const serverDiscoverer = new ServerDiscoverer(); @@ -58,7 +39,7 @@ const DiscoveredServerList = ({ serverDiscoverer.on('SERVER_ANNOUNCED', (response) => { if (!response.name) { return; } - updateServerList((prevState) => { + updateAvailableServers((prevState) => { // Detect if we already have a service with this name const serverIndex = prevState.findIndex(server => response.name === server.name); @@ -73,7 +54,7 @@ const DiscoveredServerList = ({ }); serverDiscoverer.on('SERVER_REMOVED', (response) => { - updateServerList(prevState => prevState.filter(item => (item.name !== response.name))); + updateAvailableServers(prevState => prevState.filter(item => (item.name !== response.name))); }); serverDiscoverer.on('SERVER_ERROR', (serverError) => { @@ -148,15 +129,21 @@ const DiscoveredServerList = ({ { - serverList.map(server => ( + availableServers.map(server => ( handlePairingCardClick(server)} {...server} /> )) } + setShowServerAddressForm(false)} + />
    ); }; @@ -171,8 +158,5 @@ const mapDispatchToProps = { openDialog: dialogActions.openDialog, }; -const mapStateToProps = state => ({ -}); - -export default connect(mapStateToProps, mapDispatchToProps)(DiscoveredServerList); +export default connect(null, mapDispatchToProps)(DiscoveredServerList); diff --git a/src/containers/Server/PairingCodeDialog.js b/src/containers/Server/PairingCodeDialog.js index 9b70a928b5..c07792ac4a 100644 --- a/src/containers/Server/PairingCodeDialog.js +++ b/src/containers/Server/PairingCodeDialog.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; @@ -37,6 +37,8 @@ const PairingCodeDialog = (props) => { }, setState, ] = useState(initialState); + const inputRef = useRef(); + const apiClient = new ApiClient(server.pairingServiceUrl); const handleError = (error) => { @@ -145,11 +147,11 @@ const PairingCodeDialog = (props) => {
    this.inputRef.current.clearForm()} + onClick={() => inputRef.current.clearForm()} className="pairing-code-clear" > Clear diff --git a/src/containers/Server/ServerPairingOverlay.js b/src/containers/Server/ServerPairingOverlay.js deleted file mode 100644 index c86a77c0e7..0000000000 --- a/src/containers/Server/ServerPairingOverlay.js +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import ApiClient from '../../utils/ApiClient'; -import { addSecureApiUrlToServer } from '../../utils/serverAddressing'; -import { actionCreators } from '../../ducks/modules/pairedServer'; - -const initialState = { - loading: false, - pairingCode: null, - pairingRequestSalt: null, - pairingRequestId: null, -}; - -/** - * This component is responsible for pairing with a server selected or entered by the user - */ -const ServerPairingOverlay = ({ - deviceName, - setPairedServer, - onComplete, - onCancel, -}) => { - const [{ - loading, - pairingCode, - pairingRequestSalt, - pairingRequestId, - }, setState, - ] = useState(initialState); - - let apiClient; - - const handleApiError = (err) => { - console.log('api error', err); - // this.setState(emptyState); - // this.props.onError(err); - // Dispatch error action? - }; - - const requestPairingCode = () => { - setState(prevState => ({ ...prevState, loading: true })); - apiClient.requestPairing() - .then((data) => { - if (!data) { - // we aborted the request during unmount - return; - } - - setState(prevState => ({ - ...prevState, - loading: false, - pairingRequestSalt: data.salt, - pairingRequestId: data.pairingRequestId, - })); - }) - .catch(err => handleApiError(err)); - }; - - // Pairing step 2: derive a secret, send (encrypted) to server - // At this point, we have a known connection to LAN server. 'loading' state would only distract. - const confirmPairing = () => { - apiClient.confirmPairing(pairingCode, pairingRequestId, pairingRequestSalt, deviceName) - .then((pairingInfo) => { - const device = pairingInfo.device; - const server = addSecureApiUrlToServer({ - ...props.server, - securePort: pairingInfo.securePort, - sslCertificate: pairingInfo.sslCertificate, - }); - setState({ ...initialState }); - setPairedServer(server, device.id, device.secret); - }) - .then(() => onComplete()) - .catch(err => handleApiError(err)); - }; - - const completePairing = (code) => { - setState(prevState => ({ - ...prevState, - pairingCode: code, - })); - - confirmPairing(); - }; - - useEffect(() => { - apiClient = new ApiClient(this.props.server.pairingServiceUrl); - requestPairingCode(); - - return () => { - apiClient.cancelAll(); - }; - }, []); - - return ( - - { - - } - - ) -} - - - - -ServerPairingOverlay.defaultProps = { - deviceName: '', - onComplete: () => {}, - onError: () => {}, -}; - -ServerPairingOverlay.propTypes = { - deviceName: PropTypes.string, - onComplete: PropTypes.func, - onError: PropTypes.func, - server: PropTypes.shape({ - pairingServiceUrl: PropTypes.string.isRequired, - host: PropTypes.string, - }).isRequired, - setPairedServer: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, -}; - -function mapStateToProps(state) { - return { - deviceName: state.deviceSettings.description, - }; -} - -function mapDispatchToProps(dispatch) { - return { - setPairedServer: bindActionCreators(actionCreators.setPairedServer, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ServerPairingOverlay); - -export { ServerPairingOverlay as UnconnectedServerPairingOverlay }; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index af632decaf..0e0840db3d 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { connect } from 'react-redux'; import { Button } from '@codaco/ui'; @@ -9,6 +9,7 @@ import { actionCreators as protocolActions } from '../../ducks/modules/importPro import { SettingsMenuButton } from '../../components/SettingsMenu'; import { openExternalLink } from '../../components/ExternalLink'; import Switch from './Switch'; +import getVersion from '../../utils/getVersion'; const HeaderSection = (props) => { const { @@ -17,6 +18,10 @@ const HeaderSection = (props) => { importProtocolFromURI, } = props; + const [appVersion, setAppVersion] = useState('0.0.0'); + + getVersion().then(version => setAppVersion(version)); + const start = { visible: { height: '100%', @@ -43,7 +48,7 @@ const HeaderSection = (props) => {
    Network Canvas
    -
    5.2.0
    +
    {appVersion}
    diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index 3ac5e9fda1..e0c284c2f7 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -6,6 +6,8 @@ import { Section } from '.'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; import ProtocolUrlForm from './ProtocolUrlForm'; import importLocalProtocol from '../../utils/protocol/importLocalProtocol'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; +import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; const ImportSection = (props) => { const { @@ -15,48 +17,61 @@ const ImportSection = (props) => { showProtocolUrlForm, } = props; + const onlineStatus = useOnlineStatus(); + const pairedServerConnection = useServerConnectionStatus(pairedServer); + console.log('import', onlineStatus, pairedServerConnection); return (
    -
    -
    + +

    Import a Protocol

    -
    + - - -

    Import

    -

    From URL

    -
    - -

    Import

    -

    From File

    -
    + { - pairedServer && ( - -

    Import

    -

    From Server

    -
    + +

    Import

    +

    From URL

    +
    +
    + ) + } + { + onlineStatus && pairedServerConnection === 'ok' && ( + + +

    Import

    +

    From Server

    +
    +
    ) } -
    + { Object.keys(installedProtocols).length > 0 && ( -
    - -
    + + + )}
    diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index 0a9fc16605..a89177a656 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { submit, isValid } from 'redux-form'; import { connect } from 'react-redux'; @@ -47,28 +47,19 @@ const ServerAddressForm = ({ submittable, openDialog, submitForm, + server, }) => { const [showPairingCodeDialog, setShowPairingCodeDialog] = useState(false); - const [selectedServer, setSelectedServer] = useState(null); + const [selectedServer, setSelectedServer] = useState(server); const handleSubmit = (values) => { const serverWithPairingUrl = addPairingUrlToService({ + host: values.serverAddress, addresses: [values.serverAddress], port: values.serverPort, }); setSelectedServer(serverWithPairingUrl); - - if (serverWithPairingUrl.pairingServiceUrl) { - // Now show the pairing key - setShowPairingCodeDialog(true); - } else { - openDialog({ - type: 'Error', - error: 'Pairing request failed. An error occurred while attempting to send the pairing request.', - confirmLabel: 'Okay', - }); - } }; const formConfig = { @@ -92,8 +83,7 @@ const ServerAddressForm = ({ }; const initialValues = { - serverPort: 51001, - serverAddress: 'localhost', + serverPort: 61001, }; const RenderPairingForm = () => ( @@ -137,6 +127,25 @@ const ServerAddressForm = ({ /> ); + useEffect(() => { + setSelectedServer(server); + if (selectedServer && !selectedServer.pairingServiceUrl) { + openDialog({ + type: 'Error', + error: 'Pairing request failed. An error occurred while attempting to send the pairing request.', + confirmLabel: 'Okay', + }); + + return; + } + + if (selectedServer) { + // We have a selectedServer with a pairingServiceUrl + // Now show the pairing key dialog + setShowPairingCodeDialog(true); + } + }, [server, selectedServer]); + return ( { - const { - showServerAddressForm, - toggleShowServerAddressForm, - pairedServer, - } = props; - +const ServerSection = ({ + showServerAddressForm, + toggleShowServerAddressForm, + pairedServer, +}) => { const onlineStatus = useOnlineStatus(); + const pairedServerConnection = useServerConnectionStatus(pairedServer); + + const renderServerStatus = () => { + if (!onlineStatus) { + return ( +

    + Your device does not appear to have an active network connection so + it cannot communicate with Server. Connect to a network, and try again. +

    + ); + } + + if (pairedServerConnection !== 'ok') { + return ( + +

    + This device could not communicate with your paired Server. Fetching + protocols and uploading data have been disabled. Check Server is + open on your remote computer, and that your network is configured + correctly. Use the button below for more troubleshooting information. +

    + +
    + ); + } + + return ( +

    Connection to Server established. Data upload and protocol fetching are available.

    + ); + }; + + const serverStatusClasses = cx( + 'server-status', + { 'server-status--caution': !onlineStatus }, + { 'server-status--error': pairedServerConnection !== 'ok' }, + { 'server-status--ok': onlineStatus && pairedServerConnection === 'ok' }, + ); return (
    - +
    @@ -35,15 +78,30 @@ const ServerSection = (props) => {

    - ) : (

    Server Card

    )} + ) : ( +
    + +
    +
    +
    +
    +
    + { renderServerStatus() } +
    +
    +
    + )}
    { !pairedServer ? ( - ) : [ - , - , - ] + ) : + () }
    @@ -61,6 +119,7 @@ ServerSection.defaultProps = { function mapStateToProps(state) { return { pairedServer: state.pairedServer, + pairedServerConnection: state.pairedServerConnection, showServerAddressForm: state.ui.showServerAddressForm, }; } diff --git a/src/hooks/useServerConnectionStatus.js b/src/hooks/useServerConnectionStatus.js new file mode 100644 index 0000000000..c14754166e --- /dev/null +++ b/src/hooks/useServerConnectionStatus.js @@ -0,0 +1,62 @@ +import { useState, useEffect } from 'react'; +import useInterval from './useInterval'; +import ApiClient from '../utils/ApiClient'; +import useOnlineStatus from './useOnlineStatus'; + +function getServerConnectionStatus(apiClient) { + return apiClient.requestHeartbeat() + .then(() => 'ok') + .catch(() => 'error'); +} + +const useServerConnectionStatus = (pairedServer) => { + const apiClient = new ApiClient(pairedServer); + + const onlineStatus = useOnlineStatus(); + const [connectionStatus, setConnectionStatus] = useState('waiting'); + + /** + * Call the heartbeat API when the onlineStatus or the pairedServer + * changes. + */ + useEffect(() => { + console.log('use effect', pairedServer); + + if (!pairedServer) { + setConnectionStatus('waiting'); + return; + } + + + apiClient.addTrustedCert().then(() => { + getServerConnectionStatus(apiClient) + .then((response) => { + setConnectionStatus(response); + }); + }).catch(() => setConnectionStatus('error')); + }, [onlineStatus, pairedServer]); + + /** + * Re-check connection status at an interval based on if the previous + * attempt succeeded: + * - succeeded -> check again in 30 seconds + * - failed -> check again in 5 seconds + */ + const checkInterval = onlineStatus ? 10000 : 5000; + useInterval(() => { + if (!pairedServer) { + setConnectionStatus('waiting'); + return; + } + + getServerConnectionStatus(apiClient) + .then((response) => { + setConnectionStatus(response); + }); + }, checkInterval); + + return connectionStatus; +}; + +export default useServerConnectionStatus; + diff --git a/src/images/main-background.svg b/src/images/main-background.svg index fca6dc5e62..c546a88e26 100644 --- a/src/images/main-background.svg +++ b/src/images/main-background.svg @@ -1 +1 @@ -main-background \ No newline at end of file + \ No newline at end of file diff --git a/src/styles/components/settings-menu/_settings-menu.scss b/src/styles/components/settings-menu/_settings-menu.scss index ba38cf1492..5f2e3ffaf5 100644 --- a/src/styles/components/settings-menu/_settings-menu.scss +++ b/src/styles/components/settings-menu/_settings-menu.scss @@ -58,7 +58,7 @@ section { width: 1000%; - padding: unit(8); + padding: unit(8) 0; display: flex; background: var(--color-cyber-grape); @@ -81,6 +81,7 @@ .settings-element { display: flex; margin-bottom: 2rem; + margin: 0 unit(8); >.form-field-container { .form-field-toggle { @@ -103,9 +104,12 @@ &--wide { display: flex; flex-direction: column; + margin: 0 unit(8); } &--sub-item { + margin: 0 unit(8); + .form-field-container { margin: 0; display: flex; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index e2e604e09b..ccfa24ac90 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -1,17 +1,16 @@ .start-screen { --color-panel-grey: #DCDEDD; - background-image: url('../images/main-background.png'); - background-size: cover; - background-repeat: no-repeat; - background-position: center; + background-color: var(--background); + // background-image: url('../images/main-background.svg'); + // background-repeat: no-repeat; + // background-position: center; + // background-attachment: scroll; -webkit-mask-image: linear-gradient(180deg, transparent, black 1.2rem, black calc(100% - 2rem), transparent 100%); overflow-x: hidden; overflow-y: auto; -webkit-overflow-scrolling: touch; scroll-behavior: smooth; - padding-top: 1.2rem; - background-color: var(--color-white); height: 100%; width: 100%; position: absolute; @@ -207,6 +206,9 @@ overflow: hidden; border-radius: var(--border-radius); + background: transparent; + box-shadow: none; + &__wrapper { display: flex; justify-content: center; @@ -215,7 +217,7 @@ padding: unit(4); .header-brand { - flex: 0 0 unit(12); + flex: 0 0 unit(15); z-index: var(--z-global-ui); img { @@ -349,6 +351,14 @@ margin-bottom: 1.3rem; } + .paired-server-wrapper { + margin-top: unit(2); + padding: unit(2) unit(4); + background: var(--color-slate-blue--dark); + color: var(--color-white); + border-radius: var(--border-radius); + } + .discovered-server-list { background: var(--color-slate-blue); border-radius: var(--border-radius); @@ -369,7 +379,7 @@ background: var(--color-slate-blue--dark); color: var(--color-white); align-items: center; - padding: unit(1) unit(2); + padding: unit(1) unit(4); .spinner--small { --circle-size: 0.5rem; @@ -579,3 +589,68 @@ } } } + + +.server-status { + display: flex; + + &__indicator { + display: flex; + align-items: center; + justify-content: center; + padding: 0 unit(2); + + .indicator { + width: unit(2); + height: unit(2); + border-radius: 50%; + background: var(--error); + } + } + + &--error { + .server-status__indicator { + .indicator { + background: var(--error); + @keyframes blinker { + 50% { + opacity: 0; + } + } + animation: blinker 1s cubic-bezier(1, 0, 0, 1) infinite; + } + } + } + + &--caution { + .server-status__indicator { + .indicator { + background: var(--warning); + } + } + } + + &--ok { + .server-status__indicator { + .indicator { + background: var(--color-sea-green); + @keyframes pulse { + // sass-lint:disable-block no-color-literals + 0% { + box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0.8); + } + + 70% { + box-shadow: 0 0 0 2rem rgba(var(--color-sea-green---rgb), 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0); + } + } + + animation: pulse 2s infinite; + } + } + } +} diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index d35a02618f..25747cdd52 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -377,6 +377,20 @@ class ApiClient { return exportPromise; } + + /** + * Check the status of the connection to Server + */ + requestHeartbeat() { + if (!this.httpsClient) { + return Promise.reject('No secure client available'); + } + + return this.httpsClient.get('/protocols', { ...this.authHeader, cancelToken: this.cancelTokenSource.token }) + .then(resp => resp.data) + .then(json => json.data) + .catch(err => handleError(err)); + } } export default ApiClient; From a88c08187d2d429ae9d00224b0675f0f7f5716b8 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 2 Sep 2020 12:35:48 +0100 Subject: [PATCH 34/71] update serveraddressform to correctly accept server prop --- src/containers/StartScreen/ImportSection.js | 28 +++++++++++++---- .../StartScreen/ServerAddressForm.js | 10 ++++++- src/containers/StartScreen/ServerSection.js | 30 +++++++++++++++++-- src/hooks/useServerConnectionStatus.js | 3 -- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index e0c284c2f7..c11f0a4922 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -19,7 +19,7 @@ const ImportSection = (props) => { const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); - console.log('import', onlineStatus, pairedServerConnection); + return (
    @@ -31,13 +31,14 @@ const ImportSection = (props) => { { onlineStatus && ( @@ -47,16 +48,33 @@ const ImportSection = (props) => { ) } + + +

    Import

    +

    From File

    +
    +
    { onlineStatus && pairedServerConnection === 'ok' && (

    Import

    diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index a89177a656..cf9e1d4da9 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -53,12 +53,15 @@ const ServerAddressForm = ({ const [selectedServer, setSelectedServer] = useState(server); const handleSubmit = (values) => { + console.log('handlesubvmit', values); const serverWithPairingUrl = addPairingUrlToService({ host: values.serverAddress, addresses: [values.serverAddress], port: values.serverPort, }); + console.log('withpairing', serverWithPairingUrl); + setSelectedServer(serverWithPairingUrl); }; @@ -128,7 +131,12 @@ const ServerAddressForm = ({ ); useEffect(() => { - setSelectedServer(server); + // This component is passed a server object when triggered by clicking a ServerCard + // If the server prop is set, use it. + if (server) { + setSelectedServer(server); + } + if (selectedServer && !selectedServer.pairingServiceUrl) { openDialog({ type: 'Error', diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 0dabb66091..603b9f4087 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import cx from 'classnames'; import { Button } from '@codaco/ui'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; +import { actionCreators as serverActions } from '../../ducks/modules/pairedServer'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { Section } from '.'; import DiscoveredServerList from '../Server/DiscoveredServerList'; import ServerAddressForm from './ServerAddressForm'; @@ -17,16 +19,34 @@ const ServerSection = ({ showServerAddressForm, toggleShowServerAddressForm, pairedServer, + openDialog, + unpairServer, }) => { const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); + const handleUnpairRequest = () => { + openDialog({ + type: 'Warning', + title: 'Unpair from Server?', + confirmLabel: 'Unpair', + onConfirm: unpairServer, + message: ( +

    + This will remove the connection to your computer running Server. You + will not be able to upload data or fetch protocols. Are you sure you want to continue? +

    + ), + }); + }; + const renderServerStatus = () => { if (!onlineStatus) { return (

    Your device does not appear to have an active network connection so - it cannot communicate with Server. Connect to a network, and try again. + it cannot communicate with Server. Fetching protocols and uploading + data are unavailable. Connect to a network, and try again.

    ); } @@ -76,6 +96,10 @@ const ServerSection = ({ This is a one-off process that allows your devices to identify each other. Visit our documentation article on pairing to learn more.

    +

    + To begin, open Server on a computer connected to the same network as this device. + When the device appears below, click its card to start the pairing process. +

    ) : ( @@ -101,7 +125,7 @@ const ServerSection = ({ { !pairedServer ? ( ) : - () + () }
    @@ -127,6 +151,8 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { toggleShowServerAddressForm: () => dispatch(uiActions.toggle('showServerAddressForm')), + openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), + unpairServer: () => dispatch(serverActions.unpairServer()), }; } diff --git a/src/hooks/useServerConnectionStatus.js b/src/hooks/useServerConnectionStatus.js index c14754166e..cb538c9948 100644 --- a/src/hooks/useServerConnectionStatus.js +++ b/src/hooks/useServerConnectionStatus.js @@ -20,14 +20,11 @@ const useServerConnectionStatus = (pairedServer) => { * changes. */ useEffect(() => { - console.log('use effect', pairedServer); - if (!pairedServer) { setConnectionStatus('waiting'); return; } - apiClient.addTrustedCert().then(() => { getServerConnectionStatus(apiClient) .then((response) => { From f72b49e2b270623cd52d7e1ad8a3301690dbbaec Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 3 Sep 2020 16:03:55 +0100 Subject: [PATCH 35/71] implement manage protocols view --- src/behaviours/DragAndDrop/DragSource.js | 164 ++++++++---------- src/components/Cards/ProtocolCard.js | 9 +- src/components/NewFilterableListWrapper.js | 65 ++++--- src/components/NodeBin.js | 16 +- src/containers/Overlay.js | 11 +- .../StartScreen/FetchServerProtocolPicker.js | 123 +++++++++++++ src/containers/StartScreen/ImportSection.js | 16 +- .../StartScreen/ManageProtocolsOverlay.js | 96 ++++++++++ .../StartScreen/ServerAddressForm.js | 3 - src/containers/StartScreen/ServerSection.js | 17 +- .../StartScreen/StartInterviewPicker.js | 2 - src/styles/components/_draggable-preview.scss | 13 +- .../components/_new-filterable-list.scss | 8 + src/styles/components/_node-bin.scss | 1 + src/styles/components/_overlay.scss | 1 + .../components/session-panel/_sub-menu.scss | 4 +- 16 files changed, 382 insertions(+), 167 deletions(-) create mode 100644 src/containers/StartScreen/FetchServerProtocolPicker.js create mode 100644 src/containers/StartScreen/ManageProtocolsOverlay.js diff --git a/src/behaviours/DragAndDrop/DragSource.js b/src/behaviours/DragAndDrop/DragSource.js index 8bd4398948..e860abe138 100644 --- a/src/behaviours/DragAndDrop/DragSource.js +++ b/src/behaviours/DragAndDrop/DragSource.js @@ -1,131 +1,109 @@ /* eslint-disable react/no-find-dom-node, react/sort-comp */ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React, { useEffect, useRef, useState } from 'react'; import DragPreview from './DragPreview'; import DragManager, { VERTICAL_SCROLL } from './DragManager'; import { actionCreators as actions } from './reducer'; import store from './store'; const dragSource = WrappedComponent => - class DragSource extends PureComponent { - static propTypes = { - allowDrag: PropTypes.bool, - meta: PropTypes.func, - scrollDirection: PropTypes.string, - }; - - static defaultProps = { - allowDrag: true, - meta: () => ({}), - scrollDirection: VERTICAL_SCROLL, - }; - - constructor(props) { - super(props); - - this.state = {}; - - this.dragManager = null; - this.preview = null; - } - - componentDidMount() { - if (!this.props.allowDrag) { return; } - - this.dragManager = new DragManager({ - el: this.node, - onDragStart: this.onDragStart, - onDragMove: this.onDragMove, - onDragEnd: this.onDragEnd, - scrollDirection: this.props.scrollDirection, - }); - } - - componentWillUnmount() { - this.cleanupPreview(); - this.cleanupDragManager(); - } - - cleanupDragManager = () => { - if (this.dragManager) { - this.dragManager.unmount(); - this.dragManager = null; + ({ + allowDrag = true, + meta = () => ({}), + scrollDirection = VERTICAL_SCROLL, + ...rest + }) => { + const node = useRef(); + let dragManager = null; + let preview = null; + + const [isDragging, setIsDragging] = useState(false); + + const cleanupDragManager = () => { + if (dragManager) { + dragManager.unmount(); + dragManager = null; } }; - cleanupPreview = () => { - if (this.preview) { - this.preview.cleanup(); - this.preview = null; + const cleanupPreview = () => { + if (preview) { + preview.cleanup(); + preview = null; } - } - - createPreview = () => { - const draggablePreview = new DragPreview(this.node); + }; - this.preview = draggablePreview; - } + const createPreview = () => { + const draggablePreview = new DragPreview(node.current); + preview = draggablePreview; + }; - updatePreview = ({ x, y }) => { - if (this.preview) { - this.preview.position({ x, y }); + const updatePreview = ({ x, y }) => { + if (preview) { + preview.position({ x, y }); } - } + }; - setValidMove = (valid) => { - this.preview.setValidMove(valid); - } + const setValidMove = (valid) => { + preview.setValidMove(valid); + }; - onDragStart = (movement) => { - this.createPreview(); + const onDragStart = (movement) => { + createPreview(); store.dispatch( actions.dragStart({ ...movement, - meta: this.props.meta(), + meta: meta(), }), ); - this.setState({ isDragging: true }); // TODO: Should this be handled in a manager? - } + setIsDragging(true); + }; - onDragMove = ({ x, y, ...rest }) => { - this.updatePreview({ x, y }); + const onDragMove = ({ x, y, ...other }) => { + updatePreview({ x, y }); store.dispatch( actions.dragMove({ - x, y, setValidMove: this.setValidMove, ...rest, + x, y, setValidMove, ...other, }), ); - } + }; - onDragEnd = (movement) => { - this.cleanupPreview(); - this.setState({ isDragging: false }); + const onDragEnd = (movement) => { + cleanupPreview(); + setIsDragging(false); store.dispatch( actions.dragEnd(movement), ); - } - - styles = () => (this.state.isDragging ? { visibility: 'hidden' } : {}); - - render() { - const { - allowDrag, - meta, - ...rest - } = this.props; - - return ( -
    -
    { this.node = node; }}> - { this.component = component; }} /> -
    -
    - ); - } + }; + + useEffect(() => { + if (!node.current || !allowDrag) { return false; } + + dragManager = new DragManager({ + el: node.current, + onDragStart, + onDragMove, + onDragEnd, + scrollDirection, + }); + + return () => { + cleanupPreview(); + cleanupDragManager(); + }; + }, [node]); + + const styles = () => (isDragging ? { visibility: 'hidden' } : {}); + + return ( +
    + +
    + ); }; export default dragSource; diff --git a/src/components/Cards/ProtocolCard.js b/src/components/Cards/ProtocolCard.js index efdc8ba39e..7ab1b650a3 100644 --- a/src/components/Cards/ProtocolCard.js +++ b/src/components/Cards/ProtocolCard.js @@ -25,7 +25,7 @@ const ProtocolCard = (props) => { schemaVersion !== APP_SCHEMA_VERSION && APP_SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion); - const isObsoleteProtocol = () => false; // To be implemented in future, as needed. + const isObsoleteProtocol = () => !APP_SUPPORTED_SCHEMA_VERSIONS.includes(schemaVersion); const handleSchemaOutdatedInfo = () => { openDialog({ @@ -104,14 +104,14 @@ const ProtocolCard = (props) => { description={description} isOutdated={isOutdatedProtocol()} isObsolete={isObsoleteProtocol()} - onStatusClickHandler={() => handleStatusClick} + onStatusClickHandler={handleStatusClick} onClickHandler={onClickHandler} /> ); }; ProtocolCard.defaultProps = { - onClickHandler: () => {}, + onClickHandler: undefined, description: null, condensed: false, }; @@ -123,7 +123,7 @@ ProtocolCard.propTypes = { attributes: PropTypes.shape({ schemaVersion: PropTypes.number.isRequired, lastModified: PropTypes.string.isRequired, - installationDate: PropTypes.number.isRequired, + installationDate: PropTypes.number, name: PropTypes.string.isRequired, description: PropTypes.string, }).isRequired, @@ -135,4 +135,3 @@ const mapDispatchToProps = { }; export default connect(null, mapDispatchToProps)(ProtocolCard); - diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index 6a305262a8..9f35481649 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -3,7 +3,7 @@ import { get } from 'lodash'; import objectHash from 'object-hash'; import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; import PropTypes from 'prop-types'; -import { Button } from '@codaco/ui'; +import { Button, Spinner } from '@codaco/ui'; import { Text } from '@codaco/ui/lib/components/Fields'; import { entityAttributesProperty } from '../ducks/modules/network'; import sortOrder from '../utils/sortOrder'; @@ -16,6 +16,7 @@ const NewFilterableListWrapper = (props) => { initialSortProperty, initialSortDirection, sortableProperties, + loading, } = props; const [filterTerm, setFilterTerm] = useState(null); @@ -130,31 +131,41 @@ const NewFilterableListWrapper = (props) => { />
    -
    - - - - { - sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( - - - - )) - } - - - - -
    + + { + loading ? ( +
    + +

    Loading...

    +
    + ) : ( + + + + { + sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( + + + + )) + } + + + + + ) + } +
    ); }; @@ -166,12 +177,14 @@ NewFilterableListWrapper.propTypes = { initialSortProperty: PropTypes.string.isRequired, initialSortDirection: PropTypes.oneOf(['asc', 'desc']), sortableProperties: PropTypes.array, + loading: PropTypes.bool, }; NewFilterableListWrapper.defaultProps = { initialSortDirection: 'asc', propertyPath: entityAttributesProperty, sortableProperties: [], + loading: false, }; export default NewFilterableListWrapper; diff --git a/src/components/NodeBin.js b/src/components/NodeBin.js index 29901a28c6..3b9a8e123d 100644 --- a/src/components/NodeBin.js +++ b/src/components/NodeBin.js @@ -1,10 +1,8 @@ import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import { compose, withProps } from 'recompose'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import { actionCreators as sessionsActions } from '../ducks/modules/sessions'; +import { window } from '@codaco/ui/lib/components/window'; import { DropTarget, MonitorDropTarget } from '../behaviours/DragAndDrop'; /** @@ -33,18 +31,12 @@ NodeBin.defaultProps = { willAccept: false, }; -function mapDispatchToProps(dispatch) { - return { - removeNode: bindActionCreators(sessionsActions.removeNode, dispatch), - }; -} - -export default compose( - connect(null, mapDispatchToProps), +export default window(compose( withProps(props => ({ accepts: ({ meta }) => props.accepts(meta), onDrop: ({ meta }) => props.dropHandler(meta), })), DropTarget, MonitorDropTarget(['isOver', 'willAccept']), -)(NodeBin); +)(NodeBin)); + diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index e9e520a8df..691d1e022f 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -1,5 +1,6 @@ import React from 'react'; import cx from 'classnames'; +import { motion } from 'framer-motion'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Modal } from '@codaco/ui'; @@ -21,19 +22,21 @@ const Overlay = (props) => { className, } = props; + if (!show) { return false; } + return ( -
    + { title && (

    {title}

    )} -
    + {children} -
    +
    -
    +
    ); }; diff --git a/src/containers/StartScreen/FetchServerProtocolPicker.js b/src/containers/StartScreen/FetchServerProtocolPicker.js new file mode 100644 index 0000000000..c19a1886b7 --- /dev/null +++ b/src/containers/StartScreen/FetchServerProtocolPicker.js @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from 'react'; +import { connect } from 'react-redux'; +import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; +import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; +import { NewFilterableListWrapper } from '../../components'; +import { Overlay } from '../Overlay'; +import ApiClient from '../../utils/ApiClient'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; +import { entityAttributesProperty } from '../../ducks/modules/network'; +import { ProtocolCard } from '../../components/Cards'; + +const FetchServerProtocolPicker = ({ + show, + onClose, + pairedServer, + openDialog, + importProtocolFromURI, +}) => { + const handleProtocolCardClick = (downloadPath) => { + importProtocolFromURI(downloadPath, true); + }; + + const onlineStatus = useOnlineStatus(); + + const [protocolList, setProtocolList] = useState([]); + const [loading, setLoading] = useState(false); + + const handleApiError = (error) => { + const errorObject = new Error(error); + errorObject.friendlyMessage = 'There was an error fetching the protocol list from Server. Consult the error message below for further information. Contact the Network Canvas project team for help with this error.'; + openDialog({ + type: 'Error', + title: 'Error fetching protocol list from Server', + error: errorObject, + confirmLabel: 'Okay', + onConfirm: () => { + setLoading(false); + onClose(); + }, + }); + }; + + useEffect(() => { + if (!show) { return; } + + if (!onlineStatus || !pairedServer) { onClose(); return; } + + setLoading(true); + + const apiClient = new ApiClient(pairedServer); + + apiClient + .addTrustedCert() + .then(() => apiClient.getProtocols()) + .then((protocols) => { + setProtocolList(protocols); + setLoading(false); + }) + .catch(err => handleApiError(err)); + }, [show, pairedServer, onlineStatus]); + + const formattedProtocols = [...Object.keys(protocolList)].map((protocolUID) => { + const { + schemaVersion, + lastModified, + name, + description, + downloadPath, + } = protocolList[protocolUID]; + + return { + [entityAttributesProperty]: { + schemaVersion, + lastModified, + name, + description, + }, + onClickHandler: () => handleProtocolCardClick(downloadPath), + }; + }); + + return ( + + + + ); +}; + +function mapStateToProps(state) { + return { + pairedServer: state.pairedServer, + }; +} + +const mapDispatchToProps = dispatch => ({ + addSession: (caseId, protocol) => dispatch(sessionsActions.addSession(caseId, protocol)), + openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), + importProtocolFromURI: uri => dispatch(protocolActions.importProtocolFromURI(uri, true)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(FetchServerProtocolPicker); diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index c11f0a4922..5e8ccf2402 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import { motion, AnimatePresence } from 'framer-motion'; import { GraphicButton, Button } from '@codaco/ui'; @@ -8,6 +8,7 @@ import ProtocolUrlForm from './ProtocolUrlForm'; import importLocalProtocol from '../../utils/protocol/importLocalProtocol'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; +import ManageProtocolsOverlay from './ManageProtocolsOverlay'; const ImportSection = (props) => { const { @@ -15,16 +16,19 @@ const ImportSection = (props) => { installedProtocols, toggleShowProtocolUrlForm, showProtocolUrlForm, + toggleShowFetchProtocolPicker, } = props; const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); + const [showManageProtocolsOverlay, setShowManageProtocolsOverlay] = useState(false); + return (
    -

    Import a Protocol

    +

    Protocols

    @@ -76,6 +80,7 @@ const ImportSection = (props) => { >

    Import

    From Server

    @@ -88,10 +93,14 @@ const ImportSection = (props) => {
    { Object.keys(installedProtocols).length > 0 && ( - + )} + setShowManageProtocolsOverlay(false)} + />
    ); }; @@ -114,6 +123,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { toggleShowProtocolUrlForm: () => dispatch(uiActions.toggle('showProtocolUrlForm')), + toggleShowFetchProtocolPicker: () => dispatch(uiActions.toggle('showFetchProtocolPicker')), }; } diff --git a/src/containers/StartScreen/ManageProtocolsOverlay.js b/src/containers/StartScreen/ManageProtocolsOverlay.js new file mode 100644 index 0000000000..49029bc124 --- /dev/null +++ b/src/containers/StartScreen/ManageProtocolsOverlay.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { NewFilterableListWrapper, NodeBin } from '../../components'; +import { actionCreators as installedProtocolActions } from '../../ducks/modules/installedProtocols'; +import { Overlay } from '../Overlay'; +import { DragSource } from '../../behaviours/DragAndDrop'; +import { ProtocolCard } from '../../components/Cards'; +import { entityAttributesProperty } from '../../ducks/modules/network'; + +const ManageProtocolsOverlay = ({ + show, + onClose, + installedProtocols, + deleteProtocol, +}) => { + const formattedProtocols = [...Object.keys(installedProtocols)].map((protocolUID) => { + const { + schemaVersion, + lastModified, + installationDate, + name, + description, + } = installedProtocols[protocolUID]; + + + return { + [entityAttributesProperty]: { + schemaVersion, + lastModified, + installationDate, + name, + description, + }, + meta: () => ({ protocolUID }), + }; + }); + + const DraggableProtocolCard = DragSource(ProtocolCard); + + return ( + +

    + These are the protocols that are currently installed on this device. To + delete a protocol, drag it with your mouse or finger into the bin that + will appear at the bottom of the screen. +

    + + true} + dropHandler={({ protocolUID }) => deleteProtocol(protocolUID)} + id="PROTOCOL_BIN" + /> +
    + ); +}; + +ManageProtocolsOverlay.propTypes = { + installedProtocols: PropTypes.object.isRequired, +}; + +function mapStateToProps(state) { + return { + installedProtocols: state.installedProtocols, + }; +} + +const mapDispatchToProps = { + deleteProtocol: installedProtocolActions.deleteProtocol, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ManageProtocolsOverlay); diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index cf9e1d4da9..03522e378a 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -53,15 +53,12 @@ const ServerAddressForm = ({ const [selectedServer, setSelectedServer] = useState(server); const handleSubmit = (values) => { - console.log('handlesubvmit', values); const serverWithPairingUrl = addPairingUrlToService({ host: values.serverAddress, addresses: [values.serverAddress], port: values.serverPort, }); - console.log('withpairing', serverWithPairingUrl); - setSelectedServer(serverWithPairingUrl); }; diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 603b9f4087..6b77bb2551 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -1,6 +1,5 @@ import React from 'react'; import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; import cx from 'classnames'; import { Button } from '@codaco/ui'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; @@ -14,6 +13,7 @@ import useOnlineStatus from '../../hooks/useOnlineStatus'; import { ServerCard } from '../../components/Cards'; import { openExternalLink } from '../../components/ExternalLink'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; +import FetchServerProtocolPicker from './FetchServerProtocolPicker'; const ServerSection = ({ showServerAddressForm, @@ -21,6 +21,8 @@ const ServerSection = ({ pairedServer, openDialog, unpairServer, + showFetchProtocolPicker, + toggleShowFetchProtocolPicker, }) => { const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); @@ -83,6 +85,10 @@ const ServerSection = ({ show={showServerAddressForm} handleClose={toggleShowServerAddressForm} /> +
    @@ -109,6 +115,7 @@ const ServerSection = ({ host={pairedServer.host} addresses={pairedServer.addresses} disabled={!onlineStatus || pairedServerConnection !== 'ok'} + handleServerCardClick={pairedServerConnection === 'ok' ? toggleShowFetchProtocolPicker : undefined} />
    @@ -134,23 +141,19 @@ const ServerSection = ({ ); }; -ServerSection.propTypes = { -}; - -ServerSection.defaultProps = { -}; - function mapStateToProps(state) { return { pairedServer: state.pairedServer, pairedServerConnection: state.pairedServerConnection, showServerAddressForm: state.ui.showServerAddressForm, + showFetchProtocolPicker: state.ui.showFetchProtocolPicker, }; } function mapDispatchToProps(dispatch) { return { toggleShowServerAddressForm: () => dispatch(uiActions.toggle('showServerAddressForm')), + toggleShowFetchProtocolPicker: () => dispatch(uiActions.toggle('showFetchProtocolPicker')), openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), unpairServer: () => dispatch(serverActions.unpairServer()), }; diff --git a/src/containers/StartScreen/StartInterviewPicker.js b/src/containers/StartScreen/StartInterviewPicker.js index 804a63bd31..59ee375bb2 100644 --- a/src/containers/StartScreen/StartInterviewPicker.js +++ b/src/containers/StartScreen/StartInterviewPicker.js @@ -25,11 +25,9 @@ const StartInterviewPicker = ({ addSession(caseId, selectedProtocol); handleCloseNewSessionOverlay(); onClose(); - return true; }; const handleProtocolCardClick = (protocolUID) => { - console.log('handleprotocolcardclick', protocolUID); setShowNewSessionOverlay(true); setSelectedProtocol(protocolUID); }; diff --git a/src/styles/components/_draggable-preview.scss b/src/styles/components/_draggable-preview.scss index 59a3f352aa..2dda85b06e 100644 --- a/src/styles/components/_draggable-preview.scss +++ b/src/styles/components/_draggable-preview.scss @@ -1,14 +1,5 @@ .draggable-preview { - z-index: var(--z-global-ui); + z-index: var(--z-fx-sub); + opacity: 0.5; cursor: grabbing; - - .node { - font-size: calc(var(--base-node-size) * 1.1); - filter: drop-shadow(0 1rem 2rem var(--transparent-dark)); - } - - .session-card { - min-width: 25rem; - filter: drop-shadow(0 1rem 2rem var(--transparent-dark)); - } } diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index 6da1caeaec..79e5134082 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -73,6 +73,14 @@ flex-direction: column; height: 60vh; + .loading-state { + padding: unit(4); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + } + .filterable-list-scroller { height: 100%; overflow-y: auto; diff --git a/src/styles/components/_node-bin.scss b/src/styles/components/_node-bin.scss index 389739a9e9..790a911314 100644 --- a/src/styles/components/_node-bin.scss +++ b/src/styles/components/_node-bin.scss @@ -12,6 +12,7 @@ bottom: 1.8rem; left: 50%; overflow: hidden; + z-index: var(--z-fx); &--active { transform: translateY(0); diff --git a/src/styles/components/_overlay.scss b/src/styles/components/_overlay.scss index 9902277a75..28b83546a0 100644 --- a/src/styles/components/_overlay.scss +++ b/src/styles/components/_overlay.scss @@ -29,6 +29,7 @@ $close-button-size: 1rem; overflow-x: hidden; overflow-y: auto; padding: 0 .5rem; // to make scroll bar less jarring + padding-bottom: 0.2rem; // Needed so that @clickable elements dont cause scrollbars } &__close { diff --git a/src/styles/components/session-panel/_sub-menu.scss b/src/styles/components/session-panel/_sub-menu.scss index e7de91e453..cc1715fa45 100644 --- a/src/styles/components/session-panel/_sub-menu.scss +++ b/src/styles/components/session-panel/_sub-menu.scss @@ -21,7 +21,7 @@ flex-grow: 1; } - .item { + > .item { padding: unit(4); transition: background var(--animation-duration-fast) var(--animation-easing), font-weight var(--animation-duration-fast) var(--animation-easing); cursor: pointer; @@ -29,6 +29,8 @@ font-size: 0.8rem; letter-spacing: 0.15em; text-transform: uppercase; + display: flex; + align-items: center; svg { &.icon { From 03d8933577277f2cd3dd1e35f46b566a19504fc7 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 10 Sep 2020 16:31:03 +0100 Subject: [PATCH 36/71] add editor role for .netcanvas files --- build-resources/file.icns | Bin 0 -> 201222 bytes build-resources/file.ico | Bin 0 -> 117070 bytes package-lock.json | 34 +++++++++++------- package.json | 8 ++++- public/components/windowManager.js | 2 +- src/containers/Overlay.js | 4 +-- src/containers/Server/DiscoveredServerList.js | 3 +- src/containers/Server/PairingCodeDialog.js | 5 +-- .../StartScreen/ServerAddressForm.js | 4 +-- src/styles/containers/StartScreen/_all.scss | 8 ++--- 10 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 build-resources/file.icns create mode 100644 build-resources/file.ico diff --git a/build-resources/file.icns b/build-resources/file.icns new file mode 100644 index 0000000000000000000000000000000000000000..3b273047f55da8001a9d5fe734cc0574511f7f34 GIT binary patch literal 201222 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 S-2$Ev1@5Zqs#Gi4M*a_eN%w>R literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 36604fd837..f24ca4470b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1197,18 +1197,22 @@ } }, "@codaco/ui": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@codaco/ui/-/ui-3.4.1.tgz", - "integrity": "sha512-O0uRrfwh8od1C4lDWHHYB3m5la7w91QyEIvMkvjX+M3lfVF7L0MrNhfOiEGvKcRcZrLm/RghxvlQfLlLHlyeoA==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@codaco/ui/-/ui-3.5.1.tgz", + "integrity": "sha512-zZTySyqIB+Gn2AIrJEyeOIvMnBp9R2GaAQe07rYrVQ0UmG865zVw5NTcJ2OiKXbNUaUpI0ynmUgNHclmoFd+iQ==", "dev": true, "requires": { "animejs": "^2.2.0", "classnames": "^2.2.6", + "framer-motion": "^2.6.0", + "lodash": "^4.17.20", "luxon": "^1.21.3", "prop-types": "^15.6.2", "react-compound-slider": "^2.4.0", + "react-resize-aware": "^3.0.1", "react-transition-group": "^4.3.0", "recompose": "^0.30.0", + "scrollparent": "^2.0.1", "uuid": "^3.2.1" }, "dependencies": { @@ -2524,12 +2528,6 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, - "@rehooks/online-status": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rehooks/online-status/-/online-status-1.1.2.tgz", - "integrity": "sha512-tuz6RtdgqkBMZr82UqTkzPCsHNx6tpEyhTZ5OD8WhDpU4/TLzljBCseuWLlbeQiygbM9F2A4Ea7P+wyjohJXBw==", - "dev": true - }, "@scarf/scarf": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.1.0.tgz", @@ -19369,9 +19367,9 @@ } }, "luxon": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.24.1.tgz", - "integrity": "sha512-CgnIMKAWT0ghcuWFfCWBnWGOddM0zu6c4wZAWmD0NN7MZTnro0+833DF6tJep+xlxRPg4KtsYEHYLfTMBQKwYg==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz", + "integrity": "sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ==", "dev": true }, "make-dir": { @@ -22365,6 +22363,12 @@ } } }, + "react-resize-aware": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-resize-aware/-/react-resize-aware-3.0.1.tgz", + "integrity": "sha512-HdPzwdcAv+BMFQEgyacFB40G4IxNMO7tSqaMjbnAouot8LXi5/Rx3/Fv+LU2cQekqiivE1LF4sGnwQ7SnoHrpg==", + "dev": true + }, "react-router": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", @@ -24024,6 +24028,12 @@ "ajv-keywords": "^3.4.1" } }, + "scrollparent": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.0.1.tgz", + "integrity": "sha1-cV1bnMV3YPsivczDvvtb/gaxoxc=", + "dev": true + }, "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", diff --git a/package.json b/package.json index 086f7d3cf3..93d3bc1dd2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@babel/preset-env": "^7.10.4", "@babel/preset-react": "^7.10.1", "@codaco/eslint-plugin-spellcheck": "0.0.14", - "@codaco/ui": "^3.4.1", + "@codaco/ui": "^3.5.1", "@zippytech/sorty": "^2.0.0", "ajv": "^6.5.4", "animejs": "^2.2.0", @@ -213,6 +213,12 @@ "hardenedRuntime": true, "entitlements": "./build-resources/entitlements.mac.inherit.plist" }, + "fileAssociations": { + "ext": "netcanvas", + "description": "Network Canvas interview protocol", + "icon": "file", + "role": "Viewer" + }, "win": { "target": "nsis", "certificateSha1": "512E5573B1961B0ADCAFB21288935F8A3E6E656F" diff --git a/public/components/windowManager.js b/public/components/windowManager.js index 16891d5188..a259c77507 100644 --- a/public/components/windowManager.js +++ b/public/components/windowManager.js @@ -5,7 +5,7 @@ const appUrl = require('./appURL'); const isMacOS = () => process.platform === 'darwin'; const isTest = () => !!process.env.TEST; -const titlebarParameters = isMacOS() ? { titleBarStyle: 'hidden', frame: false } : { autoHideMenuBar: true }; +const titlebarParameters = isMacOS() ? { titleBarStyle: 'hidden', frame: false } : {}; let window; diff --git a/src/containers/Overlay.js b/src/containers/Overlay.js index 691d1e022f..48d583dfc2 100644 --- a/src/containers/Overlay.js +++ b/src/containers/Overlay.js @@ -26,13 +26,13 @@ const Overlay = (props) => { return ( - + { title && (

    {title}

    )} - + {children} diff --git a/src/containers/Server/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js index 510d84b36f..cfa262000d 100644 --- a/src/containers/Server/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -111,8 +111,7 @@ const DiscoveredServerList = ({

    There was a problem enabling the automatic Server discovery feature of Network Canvas. You can still pair with your computer - running Server by entering manual connection details using the - button below. + running Server by entering manual connection details.

    diff --git a/src/containers/Server/PairingCodeDialog.js b/src/containers/Server/PairingCodeDialog.js index c07792ac4a..13f39be56e 100644 --- a/src/containers/Server/PairingCodeDialog.js +++ b/src/containers/Server/PairingCodeDialog.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import { motion } from 'framer-motion'; import { Button, Spinner } from '@codaco/ui'; import { PairingCodeLength } from 'secure-comms-api/pairingCodeConfig'; import ApiClient from '../../utils/ApiClient'; @@ -115,7 +116,7 @@ const PairingCodeDialog = (props) => { }, [server]); return ( - + { loading ?
    @@ -169,7 +170,7 @@ const PairingCodeDialog = (props) => {
    } -
    + ); }; diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index 03522e378a..a141768f94 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -87,7 +87,7 @@ const ServerAddressForm = ({ }; const RenderPairingForm = () => ( - +

    If you cannot use automatic Server discovery, you may still be able to pair this device with Server by manually entering connection details. @@ -158,9 +158,7 @@ const ServerAddressForm = ({ onClose={handleClose} title={(showPairingCodeDialog ? 'Pairing...' : 'Manual Server Connection')} > - { showPairingCodeDialog ? renderPairingCode() : RenderPairingForm()} - ); diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index ccfa24ac90..433b5d661e 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -1,10 +1,10 @@ .start-screen { --color-panel-grey: #DCDEDD; background-color: var(--background); - // background-image: url('../images/main-background.svg'); - // background-repeat: no-repeat; - // background-position: center; - // background-attachment: scroll; + background-image: url('../images/main-background.svg'); + background-repeat: no-repeat; + background-position: center; + background-attachment: scroll; -webkit-mask-image: linear-gradient(180deg, transparent, black 1.2rem, black calc(100% - 2rem), transparent 100%); overflow-x: hidden; From 25a2d90f541e8aa9d3a1fc237b915854a8626f3e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 15 Sep 2020 23:33:50 +0100 Subject: [PATCH 37/71] implement toasts for protocol import --- .../SettingsMenu/Sections/DeveloperTools.js | 4 +- src/components/ToastManager.js | 17 ++ src/config.js | 3 + src/containers/App.js | 2 + src/containers/StartScreen/HeaderSection.js | 4 +- src/containers/StartScreen/ImportSection.js | 4 +- src/containers/StartScreen/ProtocolUrlForm.js | 6 +- .../StartScreen/ServerAddressForm.js | 4 +- src/containers/StartScreen/ServerSection.js | 10 - src/ducks/modules/errors.js | 3 +- ...importProtocol.js => importProtocolold.js} | 49 +++- src/ducks/modules/installedProtocols.js | 28 ++- src/ducks/modules/remote.js | 6 +- src/ducks/modules/rootEpic.js | 2 + src/ducks/modules/rootReducer.js | 4 +- src/ducks/modules/toasts.js | 86 +++++++ src/ducks/modules/toastsEpic.js | 56 +++++ src/hooks/useServerConnectionStatus.js | 2 +- src/utils/initFileOpener.js | 4 +- src/utils/protocol/checkExistingProtocol.js | 6 +- src/utils/protocol/importLocalProtocol.js | 23 -- src/utils/protocol/importProtocol.js | 223 ++++++++++++++++++ src/utils/protocol/parseProtocol.js | 2 +- 23 files changed, 474 insertions(+), 74 deletions(-) create mode 100644 src/components/ToastManager.js rename src/ducks/modules/{importProtocol.js => importProtocolold.js} (82%) create mode 100644 src/ducks/modules/toasts.js create mode 100644 src/ducks/modules/toastsEpic.js delete mode 100644 src/utils/protocol/importLocalProtocol.js create mode 100644 src/utils/protocol/importProtocol.js diff --git a/src/components/SettingsMenu/Sections/DeveloperTools.js b/src/components/SettingsMenu/Sections/DeveloperTools.js index 8c2edb9ebe..d116aacdbb 100644 --- a/src/components/SettingsMenu/Sections/DeveloperTools.js +++ b/src/components/SettingsMenu/Sections/DeveloperTools.js @@ -6,7 +6,7 @@ import { push } from 'connected-react-router'; import { motion } from 'framer-motion'; import { Button } from '@codaco/ui/lib/components'; import { Number } from '@codaco/ui/lib/components/Fields'; -import { actionCreators as importProtocolActions } from '../../../ducks/modules/importProtocol'; +import { importProtocolFromURI } from '../../../utils/protocol/importProtocol'; import { actionCreators as dialogsActions } from '../../../ducks/modules/dialogs'; import { actionCreators as mockActions } from '../../../ducks/modules/mock'; import { getAdditionalAttributesForCurrentPrompt, getNodeEntryForCurrentPrompt } from '../../../selectors/session'; @@ -19,7 +19,6 @@ const DeveloperTools = (props) => { handleResetAppData, handleAddMockNodes, shouldShowMocksItem, - importProtocolFromURI, generateMockSessions, } = props; @@ -172,7 +171,6 @@ const developerToolsHandlers = withHandlers({ const mapDispatchToProps = dispatch => ({ generateMockNodes: bindActionCreators(mockActions.generateMockNodes, dispatch), generateMockSessions: bindActionCreators(mockActions.generateMockSessions, dispatch), - importProtocolFromURI: bindActionCreators(importProtocolActions.importProtocolFromURI, dispatch), openDialog: bindActionCreators(dialogsActions.openDialog, dispatch), resetState: () => dispatch(push('/reset')), }); diff --git a/src/components/ToastManager.js b/src/components/ToastManager.js new file mode 100644 index 0000000000..7c47334eac --- /dev/null +++ b/src/components/ToastManager.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { compose, bindActionCreators } from 'redux'; +import { ToastManager } from '@codaco/ui'; +import { actionCreators as toastActions } from '../ducks/modules/toasts'; + +const mapStateToProps = state => ({ + toasts: state.toasts, +}); + +const mapDispatchToProps = dispatch => ({ + removeToast: bindActionCreators(toastActions.removeToast, dispatch), +}); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), +)(ToastManager); + diff --git a/src/config.js b/src/config.js index b8454819b4..2c4cf4b6ec 100644 --- a/src/config.js +++ b/src/config.js @@ -1,3 +1,5 @@ +const PROTOCOL_EXTENSION = '.netcanvas'; + // Target protocol schema version. Used to determine compatibility & migration const APP_SCHEMA_VERSION = 4; @@ -24,6 +26,7 @@ const ALLOWED_MARKDOWN_PROMPT_TAGS = [ ]; module.exports = { + PROTOCOL_EXTENSION, ALLOWED_MARKDOWN_TAGS, ALLOWED_MARKDOWN_PROMPT_TAGS, APP_SCHEMA_VERSION, diff --git a/src/containers/App.js b/src/containers/App.js index 5811793229..857ae40d1e 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -10,6 +10,7 @@ import { actionCreators as deviceSettingsActions } from '../ducks/modules/device import '../styles/main.scss'; import { isElectron, isCordova, isWindows, isMacOS, isLinux, isPreview, getEnv, isIOS, isAndroid } from '../utils/Environment'; import DialogManager from '../components/DialogManager'; +import ToastManager from '../components/ToastManager'; import { SettingsMenu } from '../components/SettingsMenu'; const getElectronWindow = () => { @@ -114,6 +115,7 @@ const App = ({ { children }

    + ); }; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index 0e0840db3d..547abdf2bb 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -5,7 +5,7 @@ import { Button } from '@codaco/ui'; import { Section } from '.'; import NCLogo from '../../images/NC-Round.svg'; import { actionCreators as deviceSettingsActions } from '../../ducks/modules/deviceSettings'; -import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; +import { importProtocolFromURI } from '../../utils/protocol/importProtocol'; import { SettingsMenuButton } from '../../components/SettingsMenu'; import { openExternalLink } from '../../components/ExternalLink'; import Switch from './Switch'; @@ -15,7 +15,6 @@ const HeaderSection = (props) => { const { showGettingStarted, toggleShowGettingStarted, - importProtocolFromURI, } = props; const [appVersion, setAppVersion] = useState('0.0.0'); @@ -132,7 +131,6 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { toggleShowGettingStarted: () => dispatch(deviceSettingsActions.toggleSetting('showGettingStarted')), - importProtocolFromURI: uri => dispatch(protocolActions.importProtocolFromURI(uri)), }; } diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index 5e8ccf2402..b295a91ca2 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -5,7 +5,7 @@ import { GraphicButton, Button } from '@codaco/ui'; import { Section } from '.'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; import ProtocolUrlForm from './ProtocolUrlForm'; -import importLocalProtocol from '../../utils/protocol/importLocalProtocol'; +import { beginLocalProtocolImport } from '../../utils/protocol/importProtocol'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; import ManageProtocolsOverlay from './ManageProtocolsOverlay'; @@ -62,7 +62,7 @@ const ImportSection = (props) => { >

    Import

    From File

    diff --git a/src/containers/StartScreen/ProtocolUrlForm.js b/src/containers/StartScreen/ProtocolUrlForm.js index 660e6db44a..a1e28cf684 100644 --- a/src/containers/StartScreen/ProtocolUrlForm.js +++ b/src/containers/StartScreen/ProtocolUrlForm.js @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import { Button } from '@codaco/ui'; import { isValid } from 'redux-form'; import { Overlay } from '../../containers/Overlay'; -import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; +import { importProtocolFromURI } from '../../utils/protocol/importProtocol'; import Form from '../Form'; import { required, validateUrl } from '../../utils/Validations'; @@ -12,9 +12,9 @@ const FORM_NAME = 'import_protocol_url'; const ProtocolUrlForm = ({ show, - importProtocolFromURI, handleClose, submittable, + importProtocol, }) => { const handleSubmit = (fields) => { if (fields) { @@ -75,13 +75,11 @@ ProtocolUrlForm.defaultProps = { }; ProtocolUrlForm.propTypes = { - importProtocolFromURI: PropTypes.func.isRequired, handleClose: PropTypes.func.isRequired, show: PropTypes.bool, }; const mapDispatchToProps = dispatch => ({ - importProtocolFromURI: uri => dispatch(protocolActions.importProtocolFromURI(uri)), }); const mapStateToProps = state => ({ diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index a141768f94..fa2a2ba03b 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { submit, isValid } from 'redux-form'; import { connect } from 'react-redux'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion } from 'framer-motion'; import { Button } from '@codaco/ui'; import { Form } from '../../containers/'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; @@ -158,7 +158,7 @@ const ServerAddressForm = ({ onClose={handleClose} title={(showPairingCodeDialog ? 'Pairing...' : 'Manual Server Connection')} > - { showPairingCodeDialog ? renderPairingCode() : RenderPairingForm()} + { showPairingCodeDialog ? renderPairingCode() : RenderPairingForm()} ); diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 6b77bb2551..22ea1dda59 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -13,7 +13,6 @@ import useOnlineStatus from '../../hooks/useOnlineStatus'; import { ServerCard } from '../../components/Cards'; import { openExternalLink } from '../../components/ExternalLink'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; -import FetchServerProtocolPicker from './FetchServerProtocolPicker'; const ServerSection = ({ showServerAddressForm, @@ -21,8 +20,6 @@ const ServerSection = ({ pairedServer, openDialog, unpairServer, - showFetchProtocolPicker, - toggleShowFetchProtocolPicker, }) => { const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); @@ -85,10 +82,6 @@ const ServerSection = ({ show={showServerAddressForm} handleClose={toggleShowServerAddressForm} /> -
    @@ -115,7 +108,6 @@ const ServerSection = ({ host={pairedServer.host} addresses={pairedServer.addresses} disabled={!onlineStatus || pairedServerConnection !== 'ok'} - handleServerCardClick={pairedServerConnection === 'ok' ? toggleShowFetchProtocolPicker : undefined} />
    @@ -146,14 +138,12 @@ function mapStateToProps(state) { pairedServer: state.pairedServer, pairedServerConnection: state.pairedServerConnection, showServerAddressForm: state.ui.showServerAddressForm, - showFetchProtocolPicker: state.ui.showFetchProtocolPicker, }; } function mapDispatchToProps(dispatch) { return { toggleShowServerAddressForm: () => dispatch(uiActions.toggle('showServerAddressForm')), - toggleShowFetchProtocolPicker: () => dispatch(uiActions.toggle('showFetchProtocolPicker')), openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), unpairServer: () => dispatch(serverActions.unpairServer()), }; diff --git a/src/ducks/modules/errors.js b/src/ducks/modules/errors.js index fc48e9f541..ffa47c7a19 100644 --- a/src/ducks/modules/errors.js +++ b/src/ducks/modules/errors.js @@ -3,12 +3,11 @@ import { combineEpics } from 'redux-observable'; import { filter, map } from 'rxjs/operators'; import { actionCreators as dialogActions } from './dialogs'; -import { actionTypes as importProtocolActionTypes } from './importProtocol'; import { actionTypes as exportActionTypes } from './exportProcess'; import { actionTypes as serverActionTypes } from './pairedServer'; const errorActions = [ - importProtocolActionTypes.IMPORT_PROTOCOL_FAILED, + // importProtocolActionTypes.IMPORT_PROTOCOL_FAILED, serverActionTypes.SERVER_PAIRING_FAILED, exportActionTypes.SESSION_EXPORT_FATAL_ERROR, ]; diff --git a/src/ducks/modules/importProtocol.js b/src/ducks/modules/importProtocolold.js similarity index 82% rename from src/ducks/modules/importProtocol.js rename to src/ducks/modules/importProtocolold.js index 1694da741d..caecd3ac9b 100644 --- a/src/ducks/modules/importProtocol.js +++ b/src/ducks/modules/importProtocolold.js @@ -1,10 +1,12 @@ +import React from 'react'; +import { ProgressBar, Spinner } from '@codaco/ui'; import { CancellationError } from 'builder-util-runtime'; - import { removeDirectory } from '../../utils/filesystem'; import downloadProtocol from '../../utils/protocol/downloadProtocol'; import extractProtocol from '../../utils/protocol/extractProtocol'; import parseProtocol from '../../utils/protocol/parseProtocol'; import checkExistingProtocol, { moveToExistingProtocol } from '../../utils/protocol/checkExistingProtocol'; +import { actionCreators as toastActions, toastTypes } from './toasts'; import protocolPath from '../../utils/protocol/protocolPath'; const DOWNLOAD_PROTOCOL = 'DOWNLOAD_PROTOCOL'; @@ -144,25 +146,54 @@ const importProtocolFromURI = (uri, usePairedServer) => (dispatch, getState) => pairedServer = getState().pairedServer; } - return new Promise((resolve, reject) => { + return new Promise((resolve) => { dispatch(checkExistingProtocolAction()); checkExistingProtocol(dispatch, getState(), protocolName) .then((existingUid) => { previousUid = existingUid; dispatch(importProtocolStartAction()); + dispatch(toastActions.addToast({ + id: previousUid, + type: toastTypes.info, + title: 'Importing Protocol...', + autoDismiss: false, + content: ( + + + + ), + })); dispatch(downloadProtocolAction()); return downloadProtocol(uri, pairedServer); }) .then((tempLocation) => { if (getState().importProtocol.step === 0) return cancelledImport(); dispatch(extractProtocolAction()); + dispatch(toastActions.updateToast(previousUid, { + type: toastTypes.info, + title: 'Extracting to temporary storage...', + content: ( + + + + ), + })); return extractProtocol(tempLocation); }, catchError) .then((protocolLocation) => { protocolUid = protocolLocation; if (getState().importProtocol.step === 0) return cancelledImport(); dispatch(parseProtocolAction()); + dispatch(toastActions.updateToast(previousUid, { + type: toastTypes.info, + title: 'Validating protocol...', + content: ( + + + + ), + })); return parseProtocol(protocolLocation, protocolName); }, catchError) .then((protocolContent) => { @@ -175,6 +206,17 @@ const importProtocolFromURI = (uri, usePairedServer) => (dispatch, getState) => .then((protocolContent) => { if (getState().importProtocol.step === 0) return cancelledImport(); dispatch(importProtocolCompleteAction(protocolContent)); + dispatch(toastActions.removeToast(previousUid)); + dispatch(toastActions.addToast({ + type: toastTypes.success, + title: 'Finished!', + autoDismiss: true, + content: ( + +

    Protocol installed successfully.

    +
    + ), + })); return resolve(); }, catchError) .catch( @@ -185,8 +227,6 @@ const importProtocolFromURI = (uri, usePairedServer) => (dispatch, getState) => } else { dispatch(importProtocolFailedAction(error)); } - - reject(); }, ); }); @@ -199,7 +239,6 @@ const importProtocolFromFile = (filePath, name) => (dispatch, getState) => { const filename = filenameFromPath(filePath); const protocolName = protocolNameFromFilename(name || filename); - dispatch(checkExistingProtocolAction()); return checkExistingProtocol(dispatch, getState(), protocolName) .then((existingUid) => { diff --git a/src/ducks/modules/installedProtocols.js b/src/ducks/modules/installedProtocols.js index 376583065b..fe6aba2bd1 100644 --- a/src/ducks/modules/installedProtocols.js +++ b/src/ducks/modules/installedProtocols.js @@ -1,10 +1,9 @@ import React, { Fragment } from 'react'; import { omit, findKey } from 'lodash'; -import { actionTypes as importProtocolActions } from './importProtocol'; import { actionCreators as dialogActions } from './dialogs'; import deleteProtocol from '../../utils/protocol/deleteProtocol'; -const IMPORT_PROTOCOL_COMPLETE = importProtocolActions.IMPORT_PROTOCOL_COMPLETE; +const IMPORT_PROTOCOL_COMPLETE = 'IMPORT_PROTOCOL_COMPLETE'; const DELETE_PROTOCOL = 'INSTALLED_PROTOCOLS/DELETE_PROTOCOL'; const initialState = {}; @@ -33,12 +32,14 @@ const confirmDeleteDialog = { const hasNonExportedSessionDialog = { type: 'Warning', - title: 'Non-exported session(s) using protocol', + title: 'Interviews using protocol have not been exported', message: ( - There are sessions that use this protocol which have not yet been exported. -

    - Deleting this protocol will also delete associated sessions. +

    + There are interview sessions on this device using this protocol that have + not yet been exported. +

    +

    Deleting this protocol will also delete these sessions.

    ), confirmLabel: 'Delete protocol and sessions', @@ -46,12 +47,11 @@ const hasNonExportedSessionDialog = { const hasSessionDialog = { type: 'Confirm', - title: 'Session(s) using protocol', + title: 'Interviews using this protocol', message: ( - There are sessions that use this protocol. -

    - Deleting this protocol will also delete associated sessions. +

    There are interview sessions on this device that use this protocol.

    +

    Deleting this protocol will also delete these sessions.

    ), confirmLabel: 'Delete protocol and sessions', @@ -112,6 +112,13 @@ export default function reducer(state = initialState, action = {}) { } } +function importProtocolCompleteAction(protocolData) { + return { + type: IMPORT_PROTOCOL_COMPLETE, + protocolData, + }; +} + const actionTypes = { DELETE_PROTOCOL, IMPORT_PROTOCOL_COMPLETE, @@ -119,6 +126,7 @@ const actionTypes = { const actionCreators = { deleteProtocol: deleteProtocolAction, + importProtocolCompleteAction, }; export { diff --git a/src/ducks/modules/remote.js b/src/ducks/modules/remote.js index 3da3ac00ea..794609a95e 100644 --- a/src/ducks/modules/remote.js +++ b/src/ducks/modules/remote.js @@ -2,7 +2,7 @@ import { push } from 'connected-react-router'; import { actionCreators as resetActions } from './reset'; -import { actionCreators as importProtocolActions } from './importProtocol'; +import { actionCreators as installedProtocolActions } from './installedProtocols'; import { actionCreators as sessionActions } from './sessions'; /** @@ -27,8 +27,8 @@ const previewStage = (protocol = {}, stageIndex = 0) => dispatch(resetActions.resetAppState()); // Load protocol - dispatch(importProtocolActions.importProtocolComplete(protocol)); - dispatch(importProtocolActions.resetImportProtocol()); + dispatch(installedProtocolActions.importProtocolComplete(protocol)); + dispatch(installedProtocolActions.resetImportProtocol()); // Create session and open specified stage return dispatch(sessionActions.addSession(caseId, protocolUID)) diff --git a/src/ducks/modules/rootEpic.js b/src/ducks/modules/rootEpic.js index 4acc644b3d..0791186055 100644 --- a/src/ducks/modules/rootEpic.js +++ b/src/ducks/modules/rootEpic.js @@ -1,6 +1,8 @@ import { combineEpics } from 'redux-observable'; import { epics as errorEpics } from './errors'; +import { epics as toastsEpics } from './toastsEpic'; export default combineEpics( errorEpics, + toastsEpics, ); diff --git a/src/ducks/modules/rootReducer.js b/src/ducks/modules/rootReducer.js index f393a0499e..371508b116 100644 --- a/src/ducks/modules/rootReducer.js +++ b/src/ducks/modules/rootReducer.js @@ -6,10 +6,10 @@ import sessions from './sessions'; import activeSessionId from './session'; import activeSessionWorkers from './sessionWorkers'; import deviceSettings from './deviceSettings'; -import importProtocol from './importProtocol'; import exportProcess from './exportProcess'; import installedProtocols from './installedProtocols'; import dialogs from './dialogs'; +import toasts from './toasts'; import search from './search'; import ui from './ui'; import pairedServer from './pairedServer'; @@ -22,10 +22,10 @@ const appReducer = history => combineReducers({ activeSessionWorkers, sessions, deviceSettings, - importProtocol, exportProcess, installedProtocols, dialogs, + toasts, search, ui, pairedServer, diff --git a/src/ducks/modules/toasts.js b/src/ducks/modules/toasts.js new file mode 100644 index 0000000000..02c1f4674b --- /dev/null +++ b/src/ducks/modules/toasts.js @@ -0,0 +1,86 @@ +import uuid from 'uuid'; + +const ADD_TOAST = Symbol('PROTOCOL/ADD_TOAST'); +const UPDATE_TOAST = Symbol('PROTOCOL/UPDATE_TOAST'); +const REMOVE_TOAST = Symbol('PROTOCOL/REMOVE_TOAST'); + +const initialState = []; + +const addToast = toast => + (dispatch) => { + const id = toast.id || uuid(); + dispatch({ + type: ADD_TOAST, + toast: { + ...toast, + id, + }, + }); + + return id; + }; + +const updateToast = (id, toast) => + ({ + type: UPDATE_TOAST, + id, + toast, + }); + +const removeToast = id => + ({ + type: REMOVE_TOAST, + id, + }); + +function reducer(state = initialState, action = {}) { + switch (action.type) { + case ADD_TOAST: + return [ + ...state, + { ...action.toast }, + ]; + case UPDATE_TOAST: { + return [ + ...state.map((toast) => { + if (toast.id !== action.id) { return toast; } + return { + ...toast, + ...action.toast, + }; + }), + ]; + } + case REMOVE_TOAST: + return [ + ...state.filter(toast => toast.id !== action.id), + ]; + default: + return state; + } +} + +const actionCreators = { + addToast, + updateToast, + removeToast, +}; + +const actionTypes = { + ADD_TOAST, + REMOVE_TOAST, +}; + +const toastTypes = { + info: 'info', + warning: 'warning', + success: 'success', +}; + +export { + actionCreators, + actionTypes, + toastTypes, +}; + +export default reducer; diff --git a/src/ducks/modules/toastsEpic.js b/src/ducks/modules/toastsEpic.js new file mode 100644 index 0000000000..91c73f77e2 --- /dev/null +++ b/src/ducks/modules/toastsEpic.js @@ -0,0 +1,56 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { combineEpics } from 'redux-observable'; +import { filter, map } from 'rxjs/operators'; +import { Spinner } from '@codaco/ui'; +import { actionCreators as toastActions, toastTypes } from './toasts'; +import { actionTypes as exportActionTypes } from './exportProcess'; +import { actionTypes as serverActionTypes } from './pairedServer'; + +/** + * Data export steps + * Online/offline status change + * Server pairing + * MAKE SURE THESE DO NOT SHOW WITHIN INTERVIEW + */ + +const triggerToastActions = [ + exportActionTypes.SESSION_EXPORT_START, + exportActionTypes.SESSION_EXPORT_FINISH, + serverActionTypes.SET_SERVER, +]; + +const toastsEpic = action$ => action$.pipe( + filter(action => triggerToastActions.includes(action.type)), + map(action => { + console.log('action', action); + switch (action.type) { + case serverActionTypes.SET_SERVER: { + return toastActions.addToast({ + type: toastTypes.success, + title: 'Server set...', + content: ( + +

    Successfully paired with Server.

    +
    + ), + }); + } + default: + return toastActions.addToast({ + type: toastTypes.info, + title: 'Default toast', + content: (

    Default toast without proper content.

    ), + autoDismiss: false, + }); + } + }), +); + +const epics = combineEpics( + toastsEpic, +); + +export { + epics, +}; diff --git a/src/hooks/useServerConnectionStatus.js b/src/hooks/useServerConnectionStatus.js index cb538c9948..d72a4c645f 100644 --- a/src/hooks/useServerConnectionStatus.js +++ b/src/hooks/useServerConnectionStatus.js @@ -39,7 +39,7 @@ const useServerConnectionStatus = (pairedServer) => { * - succeeded -> check again in 30 seconds * - failed -> check again in 5 seconds */ - const checkInterval = onlineStatus ? 10000 : 5000; + const checkInterval = onlineStatus ? 30000 : 5000; useInterval(() => { if (!pairedServer) { setConnectionStatus('waiting'); diff --git a/src/utils/initFileOpener.js b/src/utils/initFileOpener.js index 320bdd2957..d4b1e57560 100644 --- a/src/utils/initFileOpener.js +++ b/src/utils/initFileOpener.js @@ -1,7 +1,7 @@ import { store } from '../ducks/store'; import { isElectron } from '../utils/Environment'; -import { actionCreators as protocolActions } from '../ducks/modules/importProtocol'; +import { importProtocolFromFile } from './protocol/importProtocol'; import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; let ipcRenderer; @@ -37,7 +37,7 @@ const initFileOpener = () => { return; } - store.dispatch(protocolActions.importProtocolFromFile(protocolPath)); + importProtocolFromFile(protocolPath); }); ipcRenderer.send('GET_ARGF'); diff --git a/src/utils/protocol/checkExistingProtocol.js b/src/utils/protocol/checkExistingProtocol.js index 457bf67551..12ae42638c 100644 --- a/src/utils/protocol/checkExistingProtocol.js +++ b/src/utils/protocol/checkExistingProtocol.js @@ -1,5 +1,6 @@ import { CancellationError } from 'builder-util-runtime'; import { findKey } from 'lodash'; +import { store } from '../../ducks/store'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { @@ -17,7 +18,10 @@ const renameProtocol = (previousUuid, currentUuid) => { .then(() => rename(currentDir, previousDir)); } -const checkExistingSession = (dispatch, state, currentName) => { +const checkExistingSession = (currentName) => { + const state = store.getState(); + const dispatch = store.dispatch; + const existingIndex = findKey(state.installedProtocols, protocol => protocol.name === currentName); const unExportedSession = findKey(state.sessions, diff --git a/src/utils/protocol/importLocalProtocol.js b/src/utils/protocol/importLocalProtocol.js deleted file mode 100644 index 6c39b35c4c..0000000000 --- a/src/utils/protocol/importLocalProtocol.js +++ /dev/null @@ -1,23 +0,0 @@ -import { store } from '../../ducks/store'; -import { isCordova, isElectron } from '../Environment'; -import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; - -const importLocalProtocol = () => { - if (isElectron()) { - const ipcRenderer = window.require('electron').ipcRenderer; - ipcRenderer.send('OPEN_DIALOG'); - } - - if (isCordova()) { - window.chooser.getFile() - .then((file) => { - if (file && file.uri) { - store.dispatch(protocolActions.importProtocolFromFile(file.uri, file.name)); - } - }); - } - - return Error('Environment not supported'); -}; - -export default importLocalProtocol; diff --git a/src/utils/protocol/importProtocol.js b/src/utils/protocol/importProtocol.js new file mode 100644 index 0000000000..3313890d86 --- /dev/null +++ b/src/utils/protocol/importProtocol.js @@ -0,0 +1,223 @@ +import React from 'react'; +import uuid from 'uuid'; +import { CancellationError } from 'builder-util-runtime'; +import { ProgressBar } from '@codaco/ui'; +import { store } from '../../ducks/store'; +import { removeDirectory } from '../../utils/filesystem'; +import { actionCreators as installedProtocolActions } from '../../ducks/modules/installedProtocols'; +import downloadProtocol from '../../utils/protocol/downloadProtocol'; +import extractProtocol from '../../utils/protocol/extractProtocol'; +import parseProtocol from '../../utils/protocol/parseProtocol'; +import checkExistingProtocol, { moveToExistingProtocol } from '../../utils/protocol/checkExistingProtocol'; +import { actionCreators as toastActions, toastTypes } from '../../ducks/modules/toasts'; +import protocolPath from '../../utils/protocol/protocolPath'; +import { PROTOCOL_EXTENSION } from '../../config'; +import { isCordova, isElectron } from '../../utils/Environment'; + +const cleanUpProtocol = (uid) => { + if (uid) { + const cancelledDir = protocolPath(uid); + removeDirectory(cancelledDir) + .catch(() => {}); // best effort + } +}; + +const cancelledImport = () => Promise.reject(new CancellationError('Import cancelled.')); + +const filenameFromURI = uri => + decodeURIComponent(uri.split('/').pop().split('#')[0].split('?')[0]); + +const filenameFromPath = path => path.split(/.*[/|\\]/)[1]; + +const protocolNameFromFilename = filename => filename.slice(0, -PROTOCOL_EXTENSION.length); + +const catchError = error => Promise.reject(error); + +const dispatch = store.dispatch; +const getState = store.getState; + +export const importProtocolFromURI = (uri, usePairedServer) => { + let cancelled = false; // Top-level cancelled property used to abort promise chain + let pairedServer; + let protocolUid; + let previousUid; + const filename = filenameFromURI(uri); + const protocolName = protocolNameFromFilename(filename); + + if (usePairedServer) { + pairedServer = getState().pairedServer; + } + + const toastUUID = uuid(); + + // Create a toast to show the status as it updates + dispatch(toastActions.addToast({ + id: toastUUID, + type: toastTypes.info, + title: 'Importing Protocol...', + autoDismiss: false, + dismissHandler: () => { + console.log('dismisshandler'); + cancelled = true; + }, + content: ( + + + + ), + })); + + const importPromise = new Promise((resolve, reject) => { + checkExistingProtocol(protocolName) + .then((existingUid) => { + previousUid = existingUid; + + dispatch(toastActions.updateToast(toastUUID, { + title: 'Downloading Protocol...', + content: ( + + + + ), + })); + return downloadProtocol(uri, pairedServer); + }) + .then((tempLocation) => { + if (cancelled) return cancelledImport(); + + dispatch(toastActions.updateToast(toastUUID, { + title: 'Extracting to temporary storage...', + content: ( + + + + ), + })); + return extractProtocol(tempLocation); + }, catchError) + .then((protocolLocation) => { + if (cancelled) return cancelledImport(); + + protocolUid = protocolLocation; + dispatch(toastActions.updateToast(toastUUID, { + title: 'Validating protocol...', + content: ( + + + + ), + })); + return parseProtocol(protocolLocation, protocolName); + }, catchError) + .then((protocolContent) => { + if (cancelled) return cancelledImport(); + if (previousUid) { + return moveToExistingProtocol(previousUid, protocolContent); + } + return protocolContent; + }) + .then((protocolContent) => { + if (cancelled) return cancelledImport(); + + // Send the payload to installedProtocols + dispatch(installedProtocolActions.importProtocolCompleteAction(protocolContent)); + // Remove the status toast + dispatch(toastActions.removeToast(toastUUID)); + dispatch(toastActions.addToast({ + type: toastTypes.success, + title: 'Finished!', + autoDismiss: true, + content: ( + +

    Protocol installed successfully.

    +
    + ), + })); + return resolve(); + }, catchError) + .catch( + (error) => { + if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files + + if (!(error instanceof CancellationError)) { + dispatch({ + type: 'IMPORT_PROTOCOL_FAILED', + error, + }); + } + + return reject(error); + }, + ); + }); + + importPromise.abort = () => { + cancelled = true; + if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files + }; + + return importPromise; +}; + +export const beginLocalProtocolImport = () => { + if (isElectron()) { + const ipcRenderer = window.require('electron').ipcRenderer; + ipcRenderer.send('OPEN_DIALOG'); + } + + if (isCordova()) { + window.chooser.getFile() + .then((file) => { + if (file && file.uri) { + importProtocolFromFile(file.uri, file.name); + } + }); + } + + return Error('Environment not supported'); +}; + + +export const importProtocolFromFile = (filePath, name) => { + let protocolUid; + let previousUid; + + const filename = filenameFromPath(filePath); + const protocolName = protocolNameFromFilename(name || filename); + + return checkExistingProtocol(protocolName) + .then((existingUid) => { + previousUid = existingUid; + dispatch(importProtocolStartAction()); + dispatch(extractProtocolAction()); + return extractProtocol(filePath); + }) + .then((protocolLocation) => { + protocolUid = protocolLocation; + if (cancelled) return cancelledImport(protocolLocation); + dispatch(parseProtocolAction()); + return parseProtocol(protocolLocation, protocolName); + }, catchError) + .then((protocolContent) => { + if (cancelled) return cancelledImport(protocolContent.uid); + if (previousUid) { + return moveToExistingProtocol(previousUid, protocolContent); + } + return protocolContent; + }) + .then((protocolContent) => { + if (cancelled) return cancelledImport(protocolContent.uid); + return dispatch(importProtocolCompleteAction(protocolContent)); + }, catchError) + .catch( + (error) => { + if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files + if (error instanceof CancellationError) { + dispatch(resetImportAction()); + } else { + dispatch(importProtocolFailedAction(error)); + } + }, + ); +}; + diff --git a/src/utils/protocol/parseProtocol.js b/src/utils/protocol/parseProtocol.js index b20af847a9..b96b9d9c0a 100644 --- a/src/utils/protocol/parseProtocol.js +++ b/src/utils/protocol/parseProtocol.js @@ -29,7 +29,7 @@ const checkSchemaVersion = (protocol) => { return Promise.resolve(protocol); } - return Promise.reject(new Error('The schema version of this protocol is not compatible with this version of Network Canvas')); + return Promise.reject(new Error('The schema version of this protocol is not compatible with this version of Network Canvas. Upgrade the protocol using Architect, and try importing it again.')); } const parseProtocol = (protocolUID, name) => From 793217bd52a79174cdbc3da5281e5a61b84fadc3 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Thu, 17 Sep 2020 23:22:22 +0100 Subject: [PATCH 38/71] work in progress final sections --- public/components/windowManager.js | 1 + .../SettingsMenu/SettingsMenuButton.js | 18 +- .../StartScreen/DataExportSection.js | 109 ++++++++++ .../StartScreen/FetchServerProtocolPicker.js | 26 +-- src/containers/StartScreen/HeaderSection.js | 98 ++++----- src/containers/StartScreen/ImportSection.js | 57 +++--- .../StartScreen/InterviewSection.js | 51 ++--- src/containers/StartScreen/ProtocolUrlForm.js | 17 +- .../StartScreen/ResumeSessionPicker.js | 31 +-- src/containers/StartScreen/Section.js | 5 +- src/containers/StartScreen/ServerSection.js | 45 ++--- src/containers/StartScreen/StartScreen.js | 4 + src/containers/StartScreen/WhatsNewSection.js | 38 ++++ src/ducks/modules/deviceSettings.js | 1 + src/ducks/modules/errors.js | 6 +- src/ducks/modules/exportProcess.js | 186 ------------------ src/ducks/modules/installedProtocols.js | 10 + src/ducks/modules/rootReducer.js | 2 - src/ducks/modules/toastsEpic.js | 19 +- src/images/undraw_file_sync.svg | 93 +++++++++ src/images/undraw_in_thought.svg | 1 + src/images/undraw_new_ideas.svg | 1 + src/images/undraw_selecting.svg | 126 ++++++++++++ .../components/session-panel/_sub-menu.scss | 2 +- .../settings-menu/_settings-menu-button.scss | 22 ++- src/styles/containers/StartScreen/_all.scss | 74 ++++--- src/utils/exportProcess.js | 183 +++++++++++++++++ src/utils/protocol/importProtocol.js | 49 +++-- 28 files changed, 799 insertions(+), 476 deletions(-) create mode 100644 src/containers/StartScreen/DataExportSection.js create mode 100644 src/containers/StartScreen/WhatsNewSection.js delete mode 100644 src/ducks/modules/exportProcess.js create mode 100644 src/images/undraw_file_sync.svg create mode 100644 src/images/undraw_in_thought.svg create mode 100644 src/images/undraw_new_ideas.svg create mode 100644 src/images/undraw_selecting.svg create mode 100644 src/utils/exportProcess.js diff --git a/public/components/windowManager.js b/public/components/windowManager.js index a259c77507..74e401e687 100644 --- a/public/components/windowManager.js +++ b/public/components/windowManager.js @@ -37,6 +37,7 @@ function createWindow() { title: 'Network Canvas', webPreferences: { nodeIntegration: true, + spellcheck: false, }, }, minDimensions, titlebarParameters); diff --git a/src/components/SettingsMenu/SettingsMenuButton.js b/src/components/SettingsMenu/SettingsMenuButton.js index 93e0e6e1d1..de3fcd808c 100644 --- a/src/components/SettingsMenu/SettingsMenuButton.js +++ b/src/components/SettingsMenu/SettingsMenuButton.js @@ -1,18 +1,18 @@ import React from 'react'; -import { Button } from '@codaco/ui'; +import { motion } from 'framer-motion'; +import { Icon } from '@codaco/ui'; import { connect } from 'react-redux'; import { compose } from 'recompose'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; const SettingsMenuButton = props => ( -
    - -
    + + +

    Settings

    +
    ); const mapDispatchToProps = dispatch => ({ diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js new file mode 100644 index 0000000000..06d5b524ce --- /dev/null +++ b/src/containers/StartScreen/DataExportSection.js @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { get } from 'lodash'; +import { useSelector } from 'react-redux'; +import { Button } from '@codaco/ui'; +import { SessionCard } from '@codaco/ui/lib/components/Cards'; +import { exportToFile, exportToServer } from '../../utils/exportProcess'; +import { Section } from '.'; +import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; +import Switch from './Switch'; +import { NewFilterableListWrapper } from '../../components'; +import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; + +const oneBasedIndex = i => parseInt(i || 0, 10) + 1; + +const DataExportSection = () => { + const [selectAll, setSelectAll] = useState(false); + + const pairedServer = useSelector(state => state.pairedServer); + const pairedServerConnection = useServerConnectionStatus(pairedServer); + + const sessions = useSelector(state => state.sessions); + const installedProtocols = useSelector(state => state.installedProtocols); + + const handleSessionCardClick = (sessionUUID) => { + console.log('clicked', sessionUUID); + // set selected state here. + }; + + const formattedSessions = [...Object.keys(sessions)].map((sessionUUID) => { + const session = sessions[sessionUUID]; + const protocol = get(installedProtocols, [session.protocolUID]); + + const progress = Math.round( + (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, + ); + + return { + caseId: session.caseId, + startedAt: session.startedAt, + updatedAt: session.updatedAt, + protocolName: protocol.name, + progress, + onClickHandler: () => handleSessionCardClick(sessionUUID), + }; + }); + + const exportSessionsToFile = () => { + const selectedSessions = Object.keys(sessions); + + exportToFile(selectedSessions.map((session) => { + const sessionProtocol = + installedProtocols[sessions[session].protocolUID]; + + return asNetworkWithSessionVariables( + session, + sessions[session], + sessionProtocol, + ); + })); + }; + + return ( +
    +
    +
    +

    Export Data

    +
    +
    +

    Content area.

    + +
    +
    +
    + setSelectAll(!!selectAll)} + /> +
    + + { pairedServerConnection === 'ok' && ()} +
    +
    +
    + ); +}; + +export default DataExportSection; diff --git a/src/containers/StartScreen/FetchServerProtocolPicker.js b/src/containers/StartScreen/FetchServerProtocolPicker.js index c19a1886b7..fcaba322be 100644 --- a/src/containers/StartScreen/FetchServerProtocolPicker.js +++ b/src/containers/StartScreen/FetchServerProtocolPicker.js @@ -1,8 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { connect } from 'react-redux'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; +import { useSelector, useDispatch } from 'react-redux'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; -import { actionCreators as protocolActions } from '../../ducks/modules/importProtocol'; +import { importProtocolFromURI } from '../../utils/protocol/importProtocol'; import { NewFilterableListWrapper } from '../../components'; import { Overlay } from '../Overlay'; import ApiClient from '../../utils/ApiClient'; @@ -13,9 +12,6 @@ import { ProtocolCard } from '../../components/Cards'; const FetchServerProtocolPicker = ({ show, onClose, - pairedServer, - openDialog, - importProtocolFromURI, }) => { const handleProtocolCardClick = (downloadPath) => { importProtocolFromURI(downloadPath, true); @@ -23,6 +19,10 @@ const FetchServerProtocolPicker = ({ const onlineStatus = useOnlineStatus(); + const dispatch = useDispatch(); + const openDialog = dialog => dispatch(dialogActions.openDialog(dialog)); + const pairedServer = useSelector(state => state.pairedServer); + const [protocolList, setProtocolList] = useState([]); const [loading, setLoading] = useState(false); @@ -108,16 +108,4 @@ const FetchServerProtocolPicker = ({ ); }; -function mapStateToProps(state) { - return { - pairedServer: state.pairedServer, - }; -} - -const mapDispatchToProps = dispatch => ({ - addSession: (caseId, protocol) => dispatch(sessionsActions.addSession(caseId, protocol)), - openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), - importProtocolFromURI: uri => dispatch(protocolActions.importProtocolFromURI(uri, true)), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(FetchServerProtocolPicker); +export default FetchServerProtocolPicker; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index 547abdf2bb..af79e4a0ee 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { connect } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@codaco/ui'; import { Section } from '.'; import NCLogo from '../../images/NC-Round.svg'; @@ -11,13 +11,11 @@ import { openExternalLink } from '../../components/ExternalLink'; import Switch from './Switch'; import getVersion from '../../utils/getVersion'; -const HeaderSection = (props) => { - const { - showGettingStarted, - toggleShowGettingStarted, - } = props; - +const HeaderSection = () => { const [appVersion, setAppVersion] = useState('0.0.0'); + const showGettingStarted = useSelector(state => state.deviceSettings.showGettingStarted); + const dispatch = useDispatch(); + const toggleShowGettingStarted = () => dispatch(deviceSettingsActions.toggleSetting('showGettingStarted')); getVersion().then(version => setAppVersion(version)); @@ -26,7 +24,7 @@ const HeaderSection = (props) => { height: '100%', opacity: 1, transition: { - delay: 1, + delay: showGettingStarted ? 0 : 1, type: 'spring', }, }, @@ -37,37 +35,35 @@ const HeaderSection = (props) => { }; return ( - -
    - -
    -

    Network Canvas

    -

    Simplifying complex network data collection.

    -
    -
    - Network Canvas -
    -
    {appVersion}
    - - - -
    -
    - - { showGettingStarted && ( - - - -
    +
    + + + + + +
    +

    Network Canvas

    +

    Simplifying complex network data collection.

    +
    +
    + Network Canvas +
    +
    {appVersion}
    +
    + + + { showGettingStarted && ( + +

    Getting Started

    @@ -108,11 +104,11 @@ const HeaderSection = (props) => {
    - - - )} - - + + )} + + + ); }; @@ -122,18 +118,6 @@ HeaderSection.propTypes = { HeaderSection.defaultProps = { }; -function mapStateToProps(state) { - return { - showGettingStarted: state.deviceSettings.showGettingStarted, - }; -} - -function mapDispatchToProps(dispatch) { - return { - toggleShowGettingStarted: () => dispatch(deviceSettingsActions.toggleSetting('showGettingStarted')), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(HeaderSection); +export default HeaderSection; export { HeaderSection as UnconnectedHeaderSection }; diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index b295a91ca2..b61aa05966 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { connect } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { motion, AnimatePresence } from 'framer-motion'; import { GraphicButton, Button } from '@codaco/ui'; import { Section } from '.'; @@ -9,21 +9,24 @@ import { beginLocalProtocolImport } from '../../utils/protocol/importProtocol'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; import ManageProtocolsOverlay from './ManageProtocolsOverlay'; +import urlIcon from '../../images/undraw_new_ideas.svg'; +import localIcon from '../../images/undraw_selecting.svg'; +import serverIcon from '../../images/undraw_file_sync.svg'; +import FetchServerProtocolPicker from './FetchServerProtocolPicker'; -const ImportSection = (props) => { - const { - pairedServer, - installedProtocols, - toggleShowProtocolUrlForm, - showProtocolUrlForm, - toggleShowFetchProtocolPicker, - } = props; - +const ImportSection = () => { const onlineStatus = useOnlineStatus(); + const pairedServer = useSelector(state => state.pairedServer); const pairedServerConnection = useServerConnectionStatus(pairedServer); - + const installedProtocols = useSelector(state => state.installedProtocols); + const showProtocolUrlForm = useSelector(state => state.ui.showProtocolUrlForm); + const showFetchProtocolPicker = useSelector(state => state.ui.showFetchProtocolPicker); const [showManageProtocolsOverlay, setShowManageProtocolsOverlay] = useState(false); + const dispatch = useDispatch(); + const toggleShowProtocolUrlForm = () => dispatch(uiActions.toggle('showProtocolUrlForm')); + const toggleShowFetchProtocolPicker = () => dispatch(uiActions.toggle('showFetchProtocolPicker')); + return (
    @@ -45,6 +48,9 @@ const ImportSection = (props) => {

    Import

    From URL

    @@ -63,6 +69,9 @@ const ImportSection = (props) => {

    Import

    From File

    @@ -81,6 +90,9 @@ const ImportSection = (props) => {

    Import

    From Server

    @@ -101,6 +113,10 @@ const ImportSection = (props) => { show={showManageProtocolsOverlay} onClose={() => setShowManageProtocolsOverlay(false)} /> + toggleShowFetchProtocolPicker()} + />
    ); }; @@ -112,21 +128,4 @@ ImportSection.defaultProps = { pairedServer: null, }; -function mapStateToProps(state) { - return { - pairedServer: state.pairedServer, - installedProtocols: state.installedProtocols, - showProtocolUrlForm: state.ui.showProtocolUrlForm, - }; -} - -function mapDispatchToProps(dispatch) { - return { - toggleShowProtocolUrlForm: () => dispatch(uiActions.toggle('showProtocolUrlForm')), - toggleShowFetchProtocolPicker: () => dispatch(uiActions.toggle('showFetchProtocolPicker')), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ImportSection); - -export { ImportSection as UnconnectedImportSection }; +export default ImportSection; diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index ccf0e4a43d..691592c76f 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { motion, AnimatePresence } from 'framer-motion'; import { Section } from '.'; import { ProtocolCard, SessionCard } from '../../components/Cards'; @@ -11,16 +11,17 @@ import StackButton from '../../components/StackButton'; import ResumeSessionPicker from './ResumeSessionPicker'; import StartInterviewPicker from './StartInterviewPicker'; -const InterviewSection = (props) => { - const { - installedProtocols, - sessions, - lastActiveSession, - addSession, - showResumeSessionPicker, - showStartInterviewPicker, - toggleUIOverlay, - } = props; +const InterviewSection = () => { + const dispatch = useDispatch(); + const addSession = (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)); + const toggleUIOverlay = overlay => dispatch(uiActions.toggle(overlay)); + + const installedProtocols = useSelector(state => state.installedProtocols); + const lastActiveSession = useSelector(state => getLastActiveSession(state)); + const sessions = useSelector(state => state.sessions); + const showResumeSessionPicker = useSelector(state => state.ui.showResumeSessionPicker); + const showStartInterviewPicker = useSelector(state => state.ui.showStartInterviewPicker); + const lastActiveProtocol = { ...installedProtocols[Object.keys(installedProtocols)[0]], @@ -151,30 +152,4 @@ const InterviewSection = (props) => { ); }; -InterviewSection.propTypes = { -}; - -InterviewSection.defaultProps = { -}; - -function mapStateToProps(state) { - return { - installedProtocols: state.installedProtocols, - showProtocolUrlForm: state.ui.showProtocolUrlForm, - showResumeSessionPicker: state.ui.showResumeSessionPicker, - showStartInterviewPicker: state.ui.showStartInterviewPicker, - sessions: state.sessions, - lastActiveSession: getLastActiveSession(state), - }; -} - -function mapDispatchToProps(dispatch) { - return { - addSession: (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)), - toggleUIOverlay: overlay => dispatch(uiActions.toggle(overlay)), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(InterviewSection); - -export { InterviewSection as UnconnectedInterviewSection }; +export default InterviewSection; diff --git a/src/containers/StartScreen/ProtocolUrlForm.js b/src/containers/StartScreen/ProtocolUrlForm.js index a1e28cf684..f613c0204b 100644 --- a/src/containers/StartScreen/ProtocolUrlForm.js +++ b/src/containers/StartScreen/ProtocolUrlForm.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { useSelector } from 'react-redux'; import { Button } from '@codaco/ui'; import { isValid } from 'redux-form'; import { Overlay } from '../../containers/Overlay'; @@ -13,9 +13,9 @@ const FORM_NAME = 'import_protocol_url'; const ProtocolUrlForm = ({ show, handleClose, - submittable, - importProtocol, }) => { + const submittable = useSelector(state => isValid(FORM_NAME)(state)); + const handleSubmit = (fields) => { if (fields) { importProtocolFromURI(fields.protocol_url).then(() => { @@ -79,13 +79,4 @@ ProtocolUrlForm.propTypes = { show: PropTypes.bool, }; -const mapDispatchToProps = dispatch => ({ -}); - -const mapStateToProps = state => ({ - submittable: isValid(FORM_NAME)(state), -}); - -export { ProtocolUrlForm }; - -export default connect(mapStateToProps, mapDispatchToProps)(ProtocolUrlForm); +export default ProtocolUrlForm; diff --git a/src/containers/StartScreen/ResumeSessionPicker.js b/src/containers/StartScreen/ResumeSessionPicker.js index 0029fc5b3c..56e9b4c15b 100644 --- a/src/containers/StartScreen/ResumeSessionPicker.js +++ b/src/containers/StartScreen/ResumeSessionPicker.js @@ -1,7 +1,5 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; +import { useSelector, useDispatch } from 'react-redux'; import { get } from 'lodash'; import { SessionCard } from '@codaco/ui/lib/components/Cards'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; @@ -13,10 +11,14 @@ const oneBasedIndex = i => parseInt(i || 0, 10) + 1; const ResumeSessionPicker = ({ show, onClose, - sessions, - setSession, - installedProtocols, }) => { + const dispatch = useDispatch(); + const setSession = sessionUID => dispatch(sessionActions.setSession(sessionUID)); + + const sessions = useSelector(state => state.sessions); + const installedProtocols = useSelector(state => state.installedProtocols); + + const handleSessionCardClick = (sessionUUID) => { setSession(sessionUUID); onClose(); @@ -71,19 +73,4 @@ const ResumeSessionPicker = ({ ); }; -ResumeSessionPicker.propTypes = { - sessions: PropTypes.object.isRequired, -}; - -function mapStateToProps(state) { - return { - sessions: state.sessions, - installedProtocols: state.installedProtocols, - }; -} - -const mapDispatchToProps = dispatch => ({ - setSession: bindActionCreators(sessionActions.setSession, dispatch), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ResumeSessionPicker); +export default ResumeSessionPicker; diff --git a/src/containers/StartScreen/Section.js b/src/containers/StartScreen/Section.js index f908fd7c1d..f7431fd409 100644 --- a/src/containers/StartScreen/Section.js +++ b/src/containers/StartScreen/Section.js @@ -11,18 +11,15 @@ const Section = (props) => { const springy = { visible: { opacity: 1, - scale: 1, transition: { - type: 'spring', + duration: 1, when: 'beforeChildren', staggerChildren: 0.5, }, }, hidden: { opacity: 0, - scale: 0.5, transition: { - type: 'spring', when: 'afterChildren', }, }, diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 22ea1dda59..063e8497f0 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -1,5 +1,5 @@ import React from 'react'; -import { connect } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import cx from 'classnames'; import { Button } from '@codaco/ui'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; @@ -8,22 +8,23 @@ import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { Section } from '.'; import DiscoveredServerList from '../Server/DiscoveredServerList'; import ServerAddressForm from './ServerAddressForm'; -import { ExternalLink } from '../../components'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import { ServerCard } from '../../components/Cards'; import { openExternalLink } from '../../components/ExternalLink'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; -const ServerSection = ({ - showServerAddressForm, - toggleShowServerAddressForm, - pairedServer, - openDialog, - unpairServer, -}) => { +const ServerSection = () => { + const showServerAddressForm = useSelector(state => state.showServerAddressForm); + const pairedServer = useSelector(state => state.pairedServer); + const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); + const dispatch = useDispatch(); + const openDialog = dialog => dispatch(dialogActions.openDialog(dialog)); + const unpairServer = () => dispatch(serverActions.unpairServer()); + const toggleShowServerAddressForm = () => dispatch(uiActions.toggle('showServerAddressForm')); + const handleUnpairRequest = () => { openDialog({ type: 'Warning', @@ -90,11 +91,6 @@ const ServerSection = ({ { !pairedServer ? ( -

    - You must pair this device with this Server before you can securely exchange data. - This is a one-off process that allows your devices to identify each other. Visit - our documentation article on pairing to learn more. -

    To begin, open Server on a computer connected to the same network as this device. When the device appears below, click its card to start the pairing process. @@ -121,6 +117,7 @@ const ServerSection = ({ )}

    + { !pairedServer ? ( ) : @@ -133,22 +130,4 @@ const ServerSection = ({ ); }; -function mapStateToProps(state) { - return { - pairedServer: state.pairedServer, - pairedServerConnection: state.pairedServerConnection, - showServerAddressForm: state.ui.showServerAddressForm, - }; -} - -function mapDispatchToProps(dispatch) { - return { - toggleShowServerAddressForm: () => dispatch(uiActions.toggle('showServerAddressForm')), - openDialog: dialog => dispatch(dialogActions.openDialog(dialog)), - unpairServer: () => dispatch(serverActions.unpairServer()), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ServerSection); - -export { ServerSection as UnconnectedServerSection }; +export default ServerSection; diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 3b28a21a01..75c51c7982 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -8,6 +8,8 @@ import { ImportSection, ServerSection, } from '.'; +import WhatsNewSection from './WhatsNewSection'; +import DataExportSection from './DataExportSection'; const StartScreen = ({ @@ -43,8 +45,10 @@ const StartScreen = ({ variants={opacity} > + + diff --git a/src/containers/StartScreen/WhatsNewSection.js b/src/containers/StartScreen/WhatsNewSection.js new file mode 100644 index 0000000000..f82599b4eb --- /dev/null +++ b/src/containers/StartScreen/WhatsNewSection.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Button } from '@codaco/ui'; +import { AnimatePresence } from 'framer-motion'; +import { Section } from '.'; +import { actionCreators as deviceActions } from '../../ducks/modules/deviceSettings'; + +const WhatsNewSection = () => { + const showWhatsNew = useSelector(state => state.deviceSettings.showWhatsNew); + + const dispatch = useDispatch(); + const dismissSection = () => dispatch(deviceActions.toggleSetting('showWhatsNew')); + + return ( + + { showWhatsNew && ( +
    +
    +
    +

    What's New

    +
    +
    +

    + Welcome to the stable release of Network Canvas. In future versions, this card + will be used to summarize changes and new features. +

    +
    +
    +
    + +
    +
    + )} +
    + ); +}; + +export default WhatsNewSection; diff --git a/src/ducks/modules/deviceSettings.js b/src/ducks/modules/deviceSettings.js index 57a573fd8d..ef31637c92 100644 --- a/src/ducks/modules/deviceSettings.js +++ b/src/ducks/modules/deviceSettings.js @@ -19,6 +19,7 @@ const initialState = { // Start screen showGettingStarted: true, + showWhatsNew: false, // Disabled for initial release // Export Options exportGraphML: true, diff --git a/src/ducks/modules/errors.js b/src/ducks/modules/errors.js index ffa47c7a19..950c57228e 100644 --- a/src/ducks/modules/errors.js +++ b/src/ducks/modules/errors.js @@ -3,13 +3,13 @@ import { combineEpics } from 'redux-observable'; import { filter, map } from 'rxjs/operators'; import { actionCreators as dialogActions } from './dialogs'; -import { actionTypes as exportActionTypes } from './exportProcess'; +import { actionTypes as installedProtocolActionTypes } from './installedProtocols'; import { actionTypes as serverActionTypes } from './pairedServer'; const errorActions = [ - // importProtocolActionTypes.IMPORT_PROTOCOL_FAILED, + installedProtocolActionTypes.IMPORT_PROTOCOL_FAILED, serverActionTypes.SERVER_PAIRING_FAILED, - exportActionTypes.SESSION_EXPORT_FATAL_ERROR, + 'SESSION_EXPORT_FATAL_ERROR', ]; const errorsEpic = action$ => action$.pipe( diff --git a/src/ducks/modules/exportProcess.js b/src/ducks/modules/exportProcess.js deleted file mode 100644 index 6aebb1080c..0000000000 --- a/src/ducks/modules/exportProcess.js +++ /dev/null @@ -1,186 +0,0 @@ -import ApiClient from '../../utils/ApiClient'; -import FileExportManager from '../../utils/network-exporters/src/FileExportManager'; - -const SESSION_EXPORT_RESET = 'SESSION_EXPORT_RESET'; -const SESSION_EXPORT_START = 'SESSION_EXPORT_START'; -const SESSION_EXPORT_UPDATE = 'SESSION_EXPORT_UPDATE'; -const SESSION_EXPORT_FINISH = 'SESSION_EXPORT_FINISH'; -const SESSION_EXPORT_ERROR = 'SESSION_EXPORT_ERROR'; -const SESSION_EXPORT_FATAL_ERROR = 'SESSION_EXPORT_FATAL_ERROR'; - - -export const initialState = { - statusText: null, - progress: 0, - errors: [], - fatalError: false, -}; - -export default function reducer(state = initialState, action = {}) { - switch (action.type) { - case SESSION_EXPORT_RESET: - return initialState; - case SESSION_EXPORT_START: - return { - ...state, - statusText: 'Starting export...', - progress: 1, - }; - case SESSION_EXPORT_UPDATE: - case SESSION_EXPORT_FINISH: - return { - ...state, - statusText: action.statusText, - progress: action.progress, - }; - case SESSION_EXPORT_ERROR: - return { - ...state, - errors: [ - ...state.errors, - action.error, - ], - }; - case SESSION_EXPORT_FATAL_ERROR: - return { - ...state, - fatalError: action.error, - }; - default: - return state; - } -} - -const sessionExportReset = () => ({ - type: SESSION_EXPORT_RESET, -}); - -const sessionExportStart = () => ({ - type: SESSION_EXPORT_START, -}); - -const sessionExportFinish = (statusText, progress) => ({ - type: SESSION_EXPORT_FINISH, - statusText, - progress, -}); - -const sessionExportUpdate = (statusText, progress) => ({ - type: SESSION_EXPORT_UPDATE, - statusText, - progress, -}); - -const sessionExportError = error => ({ - type: SESSION_EXPORT_ERROR, - error, -}); - -const sessionExportFatalError = error => ({ - type: SESSION_EXPORT_FATAL_ERROR, - error, -}); - -const exportToFile = sessionList => (dispatch, getState) => { - const { - installedProtocols, - deviceSettings: { - exportGraphML, - exportCSV, - unifyNetworks, - useScreenLayoutCoordinates, - screenLayoutHeight, - screenLayoutWidth, - }, - } = getState(); - - const exportOptions = { - exportGraphML, - exportCSV, - globalOptions: { - unifyNetworks, - useScreenLayoutCoordinates, - screenLayoutHeight, - screenLayoutWidth, - }, - }; - - const fileExportManager = new FileExportManager(exportOptions); - - fileExportManager.on('begin', () => { - dispatch(sessionExportStart()); - }); - - fileExportManager.on('update', ({ statusText, progress }) => { - dispatch(sessionExportUpdate(statusText, progress)); - }); - - fileExportManager.on('error', (error) => { - dispatch(sessionExportError(error)); - }); - - fileExportManager.on('finished', ({ statusText, progress }) => { - dispatch(sessionExportFinish(statusText, progress)); - }); - - const exportPromise = fileExportManager.exportSessions(sessionList, installedProtocols); - exportPromise.catch((error) => { - dispatch(sessionExportFatalError(error)); - }); - - return exportPromise; -}; - -const exportToServer = sessionList => (dispatch, getState) => { - const pairedServer = getState().pairedServer; - - const client = new ApiClient(pairedServer); - client.addTrustedCert(); - - client.on('begin', () => { - dispatch(sessionExportStart()); - }); - - client.on('update', ({ statusText, progress }) => { - dispatch(sessionExportUpdate(statusText, progress)); - }); - - client.on('error', (error) => { - dispatch(sessionExportError(error)); - }); - - client.on('finished', ({ statusText, progress }) => { - dispatch(sessionExportFinish(statusText, progress)); - }); - - const exportPromise = client.exportSessions(sessionList); - exportPromise.catch((error) => { - dispatch(sessionExportFatalError(error)); - }); - - return exportPromise; -}; - -const actionCreators = { - sessionExportReset, - sessionExportStart, - sessionExportFinish, - sessionExportUpdate, - sessionExportError, - sessionExportFatalError, - exportToFile, - exportToServer, -}; - -const actionTypes = { - SESSION_EXPORT_START, - SESSION_EXPORT_FINISH, - SESSION_EXPORT_UPDATE, - SESSION_EXPORT_ERROR, - SESSION_EXPORT_FATAL_ERROR, -}; - -export { - actionCreators, - actionTypes, -}; diff --git a/src/ducks/modules/installedProtocols.js b/src/ducks/modules/installedProtocols.js index fe6aba2bd1..56217717c7 100644 --- a/src/ducks/modules/installedProtocols.js +++ b/src/ducks/modules/installedProtocols.js @@ -4,6 +4,7 @@ import { actionCreators as dialogActions } from './dialogs'; import deleteProtocol from '../../utils/protocol/deleteProtocol'; const IMPORT_PROTOCOL_COMPLETE = 'IMPORT_PROTOCOL_COMPLETE'; +const IMPORT_PROTOCOL_FAILED = 'IMPORT_PROTOCOL_FAILED'; const DELETE_PROTOCOL = 'INSTALLED_PROTOCOLS/DELETE_PROTOCOL'; const initialState = {}; @@ -119,14 +120,23 @@ function importProtocolCompleteAction(protocolData) { }; } +function importProtocolFailedAction(error) { + return { + type: IMPORT_PROTOCOL_FAILED, + error, + }; +} + const actionTypes = { DELETE_PROTOCOL, IMPORT_PROTOCOL_COMPLETE, + IMPORT_PROTOCOL_FAILED, }; const actionCreators = { deleteProtocol: deleteProtocolAction, importProtocolCompleteAction, + importProtocolFailedAction, }; export { diff --git a/src/ducks/modules/rootReducer.js b/src/ducks/modules/rootReducer.js index 371508b116..820def8c82 100644 --- a/src/ducks/modules/rootReducer.js +++ b/src/ducks/modules/rootReducer.js @@ -6,7 +6,6 @@ import sessions from './sessions'; import activeSessionId from './session'; import activeSessionWorkers from './sessionWorkers'; import deviceSettings from './deviceSettings'; -import exportProcess from './exportProcess'; import installedProtocols from './installedProtocols'; import dialogs from './dialogs'; import toasts from './toasts'; @@ -22,7 +21,6 @@ const appReducer = history => combineReducers({ activeSessionWorkers, sessions, deviceSettings, - exportProcess, installedProtocols, dialogs, toasts, diff --git a/src/ducks/modules/toastsEpic.js b/src/ducks/modules/toastsEpic.js index 91c73f77e2..806dbc9637 100644 --- a/src/ducks/modules/toastsEpic.js +++ b/src/ducks/modules/toastsEpic.js @@ -2,47 +2,36 @@ import React from 'react'; import { combineEpics } from 'redux-observable'; import { filter, map } from 'rxjs/operators'; -import { Spinner } from '@codaco/ui'; import { actionCreators as toastActions, toastTypes } from './toasts'; -import { actionTypes as exportActionTypes } from './exportProcess'; import { actionTypes as serverActionTypes } from './pairedServer'; /** - * Data export steps * Online/offline status change * Server pairing * MAKE SURE THESE DO NOT SHOW WITHIN INTERVIEW */ const triggerToastActions = [ - exportActionTypes.SESSION_EXPORT_START, - exportActionTypes.SESSION_EXPORT_FINISH, serverActionTypes.SET_SERVER, ]; const toastsEpic = action$ => action$.pipe( filter(action => triggerToastActions.includes(action.type)), - map(action => { - console.log('action', action); + map((action) => { switch (action.type) { case serverActionTypes.SET_SERVER: { return toastActions.addToast({ type: toastTypes.success, - title: 'Server set...', + title: 'Pairing complete!', content: ( -

    Successfully paired with Server.

    +

    You have successfully paired with Server.

    ), }); } default: - return toastActions.addToast({ - type: toastTypes.info, - title: 'Default toast', - content: (

    Default toast without proper content.

    ), - autoDismiss: false, - }); + return null; } }), ); diff --git a/src/images/undraw_file_sync.svg b/src/images/undraw_file_sync.svg new file mode 100644 index 0000000000..30d8fac845 --- /dev/null +++ b/src/images/undraw_file_sync.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/undraw_in_thought.svg b/src/images/undraw_in_thought.svg new file mode 100644 index 0000000000..1357dea183 --- /dev/null +++ b/src/images/undraw_in_thought.svg @@ -0,0 +1 @@ +processing \ No newline at end of file diff --git a/src/images/undraw_new_ideas.svg b/src/images/undraw_new_ideas.svg new file mode 100644 index 0000000000..c959fd3b14 --- /dev/null +++ b/src/images/undraw_new_ideas.svg @@ -0,0 +1 @@ +new_ideas \ No newline at end of file diff --git a/src/images/undraw_selecting.svg b/src/images/undraw_selecting.svg new file mode 100644 index 0000000000..b1e0c6a446 --- /dev/null +++ b/src/images/undraw_selecting.svg @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/styles/components/session-panel/_sub-menu.scss b/src/styles/components/session-panel/_sub-menu.scss index cc1715fa45..a3bce4e7a4 100644 --- a/src/styles/components/session-panel/_sub-menu.scss +++ b/src/styles/components/session-panel/_sub-menu.scss @@ -21,7 +21,7 @@ flex-grow: 1; } - > .item { + .item { padding: unit(4); transition: background var(--animation-duration-fast) var(--animation-easing), font-weight var(--animation-duration-fast) var(--animation-easing); cursor: pointer; diff --git a/src/styles/components/settings-menu/_settings-menu-button.scss b/src/styles/components/settings-menu/_settings-menu-button.scss index 6a42fb5d0b..b97cac7e06 100644 --- a/src/styles/components/settings-menu/_settings-menu-button.scss +++ b/src/styles/components/settings-menu/_settings-menu-button.scss @@ -1,5 +1,21 @@ .settings-menu-button { - position: absolute; - bottom: unit(2); - right: unit(2); + display: flex; + align-items: center; + cursor: pointer; + + .icon { + &[name="settings"] { + height: unit(4); + width: unit(4); + } + } + + h4 { + font-weight: 700; + font-size: 0.8rem; + letter-spacing: 0.15em; + text-transform: uppercase; + margin: 0; + padding-left: unit(2); + } } diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 433b5d661e..c0e108f675 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -48,19 +48,11 @@ background-image: url('../images/getting-started.svg'); background-size: 50%; background-repeat: no-repeat; - background-position: 33rem 0; + background-position: 100% 0; position: relative; - .welcome-header { - &__header-toggle { - position: absolute; - right: unit(6); - top: unit(4); - } - } - - > main { - padding: unit(6); + &__main { + padding: 0 unit(6) unit(4); } .welcome-description { @@ -206,15 +198,38 @@ overflow: hidden; border-radius: var(--border-radius); - background: transparent; - box-shadow: none; + .version-string { + font-weight: bolder; + font-size: 0.64rem; + letter-spacing: .15em; + align-self: flex-start; + margin-left: 0rem; + margin-top: 1rem; + } + + &__top { + display: flex; + background: rgb(64, 64, 136); + flex: 1; + width: 100%; + align-items: center; + justify-content: space-between; + padding: unit(2) unit(4); + + .header-toggle { + .switch__label { + font-size: 0.8rem; + } + } + } &__wrapper { display: flex; justify-content: center; align-items: center; width: 100%; - padding: unit(4); + padding: unit(2) unit(4) unit(4); + background: linear-gradient(#404088, transparent); .header-brand { flex: 0 0 unit(15); @@ -244,14 +259,6 @@ } } - .version-string { - position: absolute; - right: unit(2); - top: unit(2); - font-weight: bolder; - font-size: 0.64rem; - letter-spacing: .15em; - } } .action-button { @@ -363,10 +370,12 @@ background: var(--color-slate-blue); border-radius: var(--border-radius); max-height: 30rem; + min-height: 25rem; display: flex; flex-direction: column; justify-content: center; overflow: hidden; + margin-top: unit(2); &--offline, &--error { @@ -390,10 +399,10 @@ .scrollable { display: flex; flex-wrap: wrap; - padding: unit(2) 0; + padding: unit(2); > * { - width: 50%; + max-width: 50%; } &:empty { @@ -654,3 +663,20 @@ } } } + +.whats-new-section { + background: var(--color-sea-serpent); + color: var(--color-white); + + &__content { + padding: unit(4) unit(6); + } + + &__footer { + background: var(--color-sea-serpent--dark); + padding: unit(2) unit(6); + display: flex; + align-items: center; + justify-content: flex-end; + } +} diff --git a/src/utils/exportProcess.js b/src/utils/exportProcess.js new file mode 100644 index 0000000000..e4540cb915 --- /dev/null +++ b/src/utils/exportProcess.js @@ -0,0 +1,183 @@ +import React from 'react'; +import uuid from 'uuid'; +import { ProgressBar, Scroller, Spinner, Icon } from '@codaco/ui'; +import { store } from '../ducks/store'; +import { actionCreators as toastActions, toastTypes } from '../ducks/modules/toasts'; +import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; +import ApiClient from './ApiClient'; +import FileExportManager from './network-exporters/src/FileExportManager'; + +const dispatch = store.dispatch; +const getState = store.getState; + +const showCancellationToast = () => { + dispatch(toastActions.addToast({ + type: toastTypes.warning, + title: 'Export cancelled', + content: ( + +

    You cancelled the export process.

    +
    + ), + })); +}; + +export const exportToFile = (sessionList) => { + const { + installedProtocols, + deviceSettings: { + exportGraphML, + exportCSV, + unifyNetworks, + useScreenLayoutCoordinates, + screenLayoutHeight, + screenLayoutWidth, + }, + } = getState(); + + const exportOptions = { + exportGraphML, + exportCSV, + globalOptions: { + unifyNetworks, + useScreenLayoutCoordinates, + screenLayoutHeight, + screenLayoutWidth, + }, + }; + + const errors = []; + const toastUUID = uuid(); + + const fileExportManager = new FileExportManager(exportOptions); + + fileExportManager.on('begin', () => { + // Create a toast to show the status as it updates + dispatch(toastActions.addToast({ + id: toastUUID, + type: toastTypes.info, + title: 'Exporting interviews...', + CustomIcon: (), + autoDismiss: false, + content: ( + +

    Starting export...

    + +
    + ), + })); + }); + + fileExportManager.on('update', ({ statusText, progress }) => { + dispatch(toastActions.updateToast(toastUUID, { + content: ( + +

    {statusText}

    + +
    + ), + })); + }); + + fileExportManager.on('error', (error) => { + errors.push(error); + dispatch(toastActions.updateToast(toastUUID, { + type: 'warning', + })); + }); + + fileExportManager.on('finished', () => { + dispatch(toastActions.removeToast(toastUUID)); + + if (errors.length > 0) { + const errorList = errors.map((error, index) => (
  • {error}
  • )); + + dispatch(dialogActions.openDialog({ + type: 'Warning', + title: 'Errors encountered during export', + canCancel: false, + message: ( + +

    + Your export completed, but non-fatal errors were encountered during the process. This + may mean that not all sessions or all formats were able to be exported. + Review the details of these errors below, and ensure that you check the data you + received. +

    + Errors: + +
      {errorList}
    +
    +
    + ), + })); + + return; + } + + dispatch(toastActions.addToast({ + type: toastTypes.success, + title: 'Export Complete!', + autoDismiss: true, + content: ( + +

    Your sessions were exported successfully.

    +
    + ), + })); + }); + + const exportPromise = fileExportManager.exportSessions(sessionList, installedProtocols); + + // Attatch the dismisshandler to the toast not that we have exportPromise defined. + dispatch(toastActions.updateToast(toastUUID, { + dismissHandler: () => { + showCancellationToast(); + exportPromise.abort(); + }, + })); + + // Handle fatal export errors + exportPromise.catch((error) => { + // Close the progress toast + dispatch(toastActions.removeToast(toastUUID)); + dispatch({ + type: 'SESSION_EXPORT_FATAL_ERROR', + error, + }); + + exportPromise.abort(); + }); + + return exportPromise; +}; + +export const exportToServer = (sessionList) => { + const pairedServer = getState().pairedServer; + + const client = new ApiClient(pairedServer); + client.addTrustedCert(); + + client.on('begin', () => { + dispatch(sessionExportStart()); + }); + + client.on('update', ({ statusText, progress }) => { + dispatch(sessionExportUpdate(statusText, progress)); + }); + + client.on('error', (error) => { + dispatch(sessionExportError(error)); + }); + + client.on('finished', ({ statusText, progress }) => { + dispatch(sessionExportFinish(statusText, progress)); + }); + + const exportPromise = client.exportSessions(sessionList); + exportPromise.catch((error) => { + dispatch(sessionExportFatalError(error)); + }); + + return exportPromise; +}; diff --git a/src/utils/protocol/importProtocol.js b/src/utils/protocol/importProtocol.js index 3313890d86..9c559eb15b 100644 --- a/src/utils/protocol/importProtocol.js +++ b/src/utils/protocol/importProtocol.js @@ -1,7 +1,7 @@ import React from 'react'; import uuid from 'uuid'; import { CancellationError } from 'builder-util-runtime'; -import { ProgressBar } from '@codaco/ui'; +import { ProgressBar, Spinner } from '@codaco/ui'; import { store } from '../../ducks/store'; import { removeDirectory } from '../../utils/filesystem'; import { actionCreators as installedProtocolActions } from '../../ducks/modules/installedProtocols'; @@ -36,6 +36,18 @@ const catchError = error => Promise.reject(error); const dispatch = store.dispatch; const getState = store.getState; +const showCancellationToast = () => { + dispatch(toastActions.addToast({ + type: toastTypes.warning, + title: 'Import cancelled', + content: ( + +

    You cancelled the import of this protocol.

    +
    + ), + })); +} + export const importProtocolFromURI = (uri, usePairedServer) => { let cancelled = false; // Top-level cancelled property used to abort promise chain let pairedServer; @@ -55,14 +67,15 @@ export const importProtocolFromURI = (uri, usePairedServer) => { id: toastUUID, type: toastTypes.info, title: 'Importing Protocol...', + CustomIcon: (), autoDismiss: false, dismissHandler: () => { - console.log('dismisshandler'); + showCancellationToast(); cancelled = true; }, content: ( - + ), })); @@ -76,7 +89,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { title: 'Downloading Protocol...', content: ( - + ), })); @@ -89,7 +102,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { title: 'Extracting to temporary storage...', content: ( - + ), })); @@ -103,7 +116,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { title: 'Validating protocol...', content: ( - + ), })); @@ -121,6 +134,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { // Send the payload to installedProtocols dispatch(installedProtocolActions.importProtocolCompleteAction(protocolContent)); + // Remove the status toast dispatch(toastActions.removeToast(toastUUID)); dispatch(toastActions.addToast({ @@ -137,16 +151,16 @@ export const importProtocolFromURI = (uri, usePairedServer) => { }, catchError) .catch( (error) => { - if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files + // Remove the status toast + dispatch(toastActions.removeToast(toastUUID)); - if (!(error instanceof CancellationError)) { - dispatch({ - type: 'IMPORT_PROTOCOL_FAILED', - error, - }); - } + // attempt to clean up files + if (protocolUid) cleanUpProtocol(protocolUid); - return reject(error); + // If this wasn't user cancellation, dispatch an error + if (!(error instanceof CancellationError)) { + dispatch(installedProtocolActions.importProtocolFailedAction(error)); + } }, ); }); @@ -212,10 +226,9 @@ export const importProtocolFromFile = (filePath, name) => { .catch( (error) => { if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files - if (error instanceof CancellationError) { - dispatch(resetImportAction()); - } else { - dispatch(importProtocolFailedAction(error)); + + if (!(error instanceof CancellationError)) { + dispatch(installedProtocolActions.importProtocolFailedAction(error)); } }, ); From b760f8e530857df3e467e465f6d4fa09702197ff Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 21 Sep 2020 15:43:49 +0100 Subject: [PATCH 39/71] UI finished for data export section --- src/components/Cards/SessionCard.js | 40 ++----- src/components/NewFilterableListWrapper.js | 7 +- .../StartScreen/DataExportSection.js | 104 ++++++++++++------ src/containers/StartScreen/ImportSection.js | 8 +- src/containers/StartScreen/ServerSection.js | 4 +- src/containers/StartScreen/StartScreen.js | 2 +- .../components/_new-filterable-list.scss | 9 +- src/styles/containers/StartScreen/_all.scss | 93 +++++++++++----- 8 files changed, 163 insertions(+), 104 deletions(-) diff --git a/src/components/Cards/SessionCard.js b/src/components/Cards/SessionCard.js index 713d98e614..157236c838 100644 --- a/src/components/Cards/SessionCard.js +++ b/src/components/Cards/SessionCard.js @@ -1,9 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { get } from 'lodash'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; import { SessionCard as UISessionCard } from '@codaco/ui/lib/components/Cards'; +import { useSelector, useDispatch } from 'react-redux'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; @@ -14,12 +13,17 @@ const oneBasedIndex = i => parseInt(i || 0, 10) + 1; const SessionCard = (props) => { const { sessionUUID, - setSession, - session, - progress, - protocol, } = props; + const dispatch = useDispatch(); + const setSession = session => dispatch(sessionActions.setSession(session)); + + const session = useSelector(state => state.sessions[sessionUUID]); + const protocol = useSelector(state => get(state.installedProtocols, [session.protocolUID])); + const progress = Math.round( + (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, + ); + const { caseId, startedAt, @@ -44,32 +48,10 @@ const SessionCard = (props) => { }; SessionCard.propTypes = { - progress: PropTypes.number.isRequired, sessionUUID: PropTypes.string.isRequired, - setSession: PropTypes.func.isRequired, }; SessionCard.defaultProps = { }; -const mapStateToProps = (state, props) => { - const session = state.sessions[props.sessionUUID]; - const protocol = get(state.installedProtocols, [session.protocolUID]); - - const progress = Math.round( - (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, - ); - - return { - session, - protocol, - progress, - }; -}; - -const mapDispatchToProps = dispatch => ({ - setSession: bindActionCreators(sessionActions.setSession, dispatch), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(SessionCard); - +export default SessionCard; diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index 9f35481649..3ad79275ab 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -94,7 +94,8 @@ const NewFilterableListWrapper = (props) => { const sortedAndFilteredList = getFilteredAndSortedItemList(); return ( -
    @@ -152,7 +153,7 @@ const NewFilterableListWrapper = (props) => { sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( @@ -166,7 +167,7 @@ const NewFilterableListWrapper = (props) => { ) } -
    + ); }; diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js index 06d5b524ce..9c2df4d8b4 100644 --- a/src/containers/StartScreen/DataExportSection.js +++ b/src/containers/StartScreen/DataExportSection.js @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { get } from 'lodash'; +import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; import { useSelector } from 'react-redux'; import { Button } from '@codaco/ui'; import { SessionCard } from '@codaco/ui/lib/components/Cards'; @@ -13,7 +14,7 @@ import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; const DataExportSection = () => { - const [selectAll, setSelectAll] = useState(false); + const [selectedSessions, setSelectedSessions] = useState([]); const pairedServer = useSelector(state => state.pairedServer); const pairedServerConnection = useServerConnectionStatus(pairedServer); @@ -21,32 +22,58 @@ const DataExportSection = () => { const sessions = useSelector(state => state.sessions); const installedProtocols = useSelector(state => state.installedProtocols); + const toggleSelectAll = () => { + if ((Object.keys(sessions).length !== selectedSessions.length)) { + setSelectedSessions(Object.keys(sessions)); + return; + } + + setSelectedSessions([]); + }; + const handleSessionCardClick = (sessionUUID) => { - console.log('clicked', sessionUUID); - // set selected state here. + if (selectedSessions.includes(sessionUUID)) { + setSelectedSessions([ + ...selectedSessions.filter(session => session !== sessionUUID), + ]); + + return; + } + + setSelectedSessions(alreadySelected => [ + ...alreadySelected, + sessionUUID, + ]); }; const formattedSessions = [...Object.keys(sessions)].map((sessionUUID) => { - const session = sessions[sessionUUID]; - const protocol = get(installedProtocols, [session.protocolUID]); + const session = useSelector(state => state.sessions[sessionUUID]); + const { + caseId, + startedAt, + updatedAt, + } = session; + + const protocol = useSelector(state => get(state.installedProtocols, [session.protocolUID])); const progress = Math.round( (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, ); return { - caseId: session.caseId, - startedAt: session.startedAt, - updatedAt: session.updatedAt, - protocolName: protocol.name, + caseId, progress, + startedAt, + updatedAt, + key: sessionUUID, + protocolName: protocol.name, + sessionUUID, + selected: selectedSessions.includes(sessionUUID), onClickHandler: () => handleSessionCardClick(sessionUUID), }; }); const exportSessionsToFile = () => { - const selectedSessions = Object.keys(sessions); - exportToFile(selectedSessions.map((session) => { const sessionProtocol = installedProtocols[sessions[session].protocolUID]; @@ -60,13 +87,16 @@ const DataExportSection = () => { }; return ( -
    -
    -
    -

    Export Data

    -
    -
    -

    Content area.

    + +
    + + +

    Export & Manage Interview Data

    +
    + + Select one or more interview sessions by tapping then, and then delete or export + them using the buttons provided. + { }, ]} /> -
    -
    -
    - setSelectAll(!!selectAll)} - /> -
    - - { pairedServerConnection === 'ok' && ()} -
    -
    -
    + + + { selectedSessions.length > 0 && + ({ selectedSessions.length} selected session{ selectedSessions.length > 1 ? ('s') : null }.)} + + + + +
    + { pairedServerConnection === 'ok' && ()} + +
    +
    + + ); }; diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index b61aa05966..db91752b70 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -9,7 +9,7 @@ import { beginLocalProtocolImport } from '../../utils/protocol/importProtocol'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; import ManageProtocolsOverlay from './ManageProtocolsOverlay'; -import urlIcon from '../../images/undraw_new_ideas.svg'; +import urlIcon from '../../images/undraw_in_thought.svg'; import localIcon from '../../images/undraw_selecting.svg'; import serverIcon from '../../images/undraw_file_sync.svg'; import FetchServerProtocolPicker from './FetchServerProtocolPicker'; @@ -49,7 +49,7 @@ const ImportSection = () => { color="sea-green" onClick={toggleShowProtocolUrlForm} graphicSize="13rem" - graphicPosition="0% 2rem" + graphicPosition="100% 0%" graphic={urlIcon} >

    Import

    @@ -90,8 +90,8 @@ const ImportSection = () => {

    Import

    diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 063e8497f0..0b22db6418 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -87,7 +87,7 @@ const ServerSection = () => {
    -

    Server

    +

    Server Status

    { !pairedServer ? ( @@ -117,7 +117,7 @@ const ServerSection = () => { )}
    - + { !pairedServer ? ( ) : diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 75c51c7982..50f8d0a262 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -47,8 +47,8 @@ const StartScreen = ({ - + diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index 79e5134082..eacefe9fea 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -41,16 +41,17 @@ } button { - margin: 0 1rem; + margin: 0 unit(1); } h4 { display: inline; + font-size: 0.8rem; } .form-field-container { display: inline-block; - margin: 0 1rem; + margin: 0 unit(1); } input { @@ -84,9 +85,11 @@ .filterable-list-scroller { height: 100%; overflow-y: auto; - padding: unit(2) 0; + padding: unit(2); display: grid; grid-template-columns: 1fr 1fr; + row-gap: unit(2); + column-gap: unit(2); &:empty { display: flex; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index c0e108f675..93aa5e23be 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -25,8 +25,7 @@ border-radius: var(--border-radius); box-shadow: 0 unit(1) unit(1) 0 rgba(0, 0, 0, 0.25); margin: unit(6) auto; - color: var(--color-charcoal); - max-width: 65rem; + max-width: 68rem; width: 100%; overflow: hidden; @@ -78,6 +77,7 @@ } .interview-section { + background: linear-gradient(#404088, var(--color-cyber-grape)); &__manage-protocols { padding: unit(2) unit(6); display: flex; @@ -91,8 +91,7 @@ &__start-new { padding: 0 !important; - background: var(--color-panel-grey); - color: var(--text-dark); + .content-area { display: flex; @@ -109,20 +108,13 @@ flex: 0 1 40%; display: flex; flex-direction: column; - - .stack-button { - h4 { - color: var(--text-dark); - } - } } } } &__resume-section { padding: 0 !important; - background: #cccccc; - color: var(--text-dark); + background: rgba(22, 21, 43, 0.14901960784313725); .content-area { display: flex; @@ -141,22 +133,16 @@ flex: 0 1 40%; display: flex; flex-direction: column; - - .stack-button { - h4 { - color: var(--text-dark); - } - } } } } } .import-section { - background: var(--color-slate-blue); + background: linear-gradient(#404088, var(--color-cyber-grape)); &__manage-protocols { - background: var(--color-slate-blue--dark); + background: var(--color-slate-blue); padding: unit(2) unit(6); display: flex; align-items: center; @@ -169,7 +155,6 @@ &__install-section { padding: unit(4) unit(6); - color: var(--color-white); > .content-buttons { display: flex; @@ -329,7 +314,7 @@ .server-section { &__main { - background: var(--color-panel-grey); + background: linear-gradient(#404088, var(--color-cyber-grape)); padding: 0; .content-area { @@ -341,13 +326,7 @@ background: var(--color-mustard); display: flex; align-items: center; - justify-content: flex-end; - - .button { - &:last-child { - margin-left: unit(2); - } - } + justify-content: space-between; } &__discover { @@ -364,6 +343,10 @@ background: var(--color-slate-blue--dark); color: var(--color-white); border-radius: var(--border-radius); + + .server-card { + margin: unit(2); + } } .discovered-server-list { @@ -680,3 +663,55 @@ justify-content: flex-end; } } + +.data-export-section { + background: linear-gradient(#404088, var(--color-cyber-grape)); + + &__main { + > header { + padding: unit(4) unit(6) 0; + } + + .content-area { + padding: unit(1) unit(6); + } + + .selection-status { + margin: unit(2) unit(4); + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row-reverse; + height: unit(6) + } + + .new-filterable-list { + margin: unit(4); + margin-bottom: 0; + + &__main { + max-height: 45vh; + min-height: 16rem; + } + } + } + + &__footer { + padding: unit(2) unit(6); + background-color: var(--color-neon-coral); + display: flex; + justify-content: space-between; + + .action-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + + .button { + &:last-child { + margin-left: unit(2); + } + } + } + } +} \ No newline at end of file From bc0ae389af33d05162c0c83725095b15e6063f33 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 21 Sep 2020 17:13:49 +0100 Subject: [PATCH 40/71] functioning export to server and files --- src/behaviours/withPrompt.js | 1 - src/components/Cards/SessionCard.js | 2 + src/components/NewFilterableListWrapper.js | 5 +- .../StartScreen/DataExportSection.js | 123 +++++++++------- src/containers/StartScreen/HeaderSection.js | 6 + src/containers/StartScreen/ServerSection.js | 4 +- src/ducks/modules/session.js | 2 +- src/ducks/modules/sessions.js | 31 +++- src/styles/containers/StartScreen/_all.scss | 20 +++ src/utils/ApiClient.js | 2 + src/utils/exportProcess.js | 133 ++++++++++++++---- src/utils/network-exporters | 2 +- 12 files changed, 237 insertions(+), 94 deletions(-) diff --git a/src/behaviours/withPrompt.js b/src/behaviours/withPrompt.js index 0863b4449f..4e4487e613 100644 --- a/src/behaviours/withPrompt.js +++ b/src/behaviours/withPrompt.js @@ -2,7 +2,6 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; - import { actionCreators as sessionsActions } from '../ducks/modules/sessions'; import { getPromptIndexForCurrentSession } from '../selectors/session'; import { getProtocolStages } from '../selectors/protocol'; diff --git a/src/components/Cards/SessionCard.js b/src/components/Cards/SessionCard.js index 157236c838..dc075e17f3 100644 --- a/src/components/Cards/SessionCard.js +++ b/src/components/Cards/SessionCard.js @@ -28,6 +28,7 @@ const SessionCard = (props) => { caseId, startedAt, updatedAt, + exportedAt, } = session; const onClickLoadSession = (event) => { @@ -40,6 +41,7 @@ const SessionCard = (props) => { caseId={caseId} startedAt={startedAt} updatedAt={updatedAt} + exportedAt={exportedAt} protocolName={protocol.name} progress={progress} onClickHandler={onClickLoadSession} diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index 3ad79275ab..a88b127871 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -94,8 +94,7 @@ const NewFilterableListWrapper = (props) => { const sortedAndFilteredList = getFilteredAndSortedItemList(); return ( -
    @@ -167,7 +166,7 @@ const NewFilterableListWrapper = (props) => { ) } - +
    ); }; diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js index 9c2df4d8b4..d2fc7d540b 100644 --- a/src/containers/StartScreen/DataExportSection.js +++ b/src/containers/StartScreen/DataExportSection.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { get } from 'lodash'; import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@codaco/ui'; import { SessionCard } from '@codaco/ui/lib/components/Cards'; import { exportToFile, exportToServer } from '../../utils/exportProcess'; @@ -22,6 +22,8 @@ const DataExportSection = () => { const sessions = useSelector(state => state.sessions); const installedProtocols = useSelector(state => state.installedProtocols); + const dispatch = useDispatch(); + const toggleSelectAll = () => { if ((Object.keys(sessions).length !== selectedSessions.length)) { setSelectedSessions(Object.keys(sessions)); @@ -53,6 +55,7 @@ const DataExportSection = () => { caseId, startedAt, updatedAt, + exportedAt, } = session; const protocol = useSelector(state => get(state.installedProtocols, [session.protocolUID])); @@ -65,6 +68,7 @@ const DataExportSection = () => { progress, startedAt, updatedAt, + exportedAt, key: sessionUUID, protocolName: protocol.name, sessionUUID, @@ -86,61 +90,72 @@ const DataExportSection = () => { })); }; + const exportSessionsToServer = () => { + exportToServer(selectedSessions.map((session) => { + const sessionProtocol = + installedProtocols[sessions[session].protocolUID]; + + return asNetworkWithSessionVariables( + session, + sessions[session], + sessionProtocol, + ); + })); + }; + return ( - -
    - - -

    Export & Manage Interview Data

    -
    - - Select one or more interview sessions by tapping then, and then delete or export - them using the buttons provided. - - + + +

    Export & Manage Interview Data

    +
    + + Select one or more interview sessions by tapping then, and then delete or export + them using the buttons provided. + + + + - - - { selectedSessions.length > 0 && - ({ selectedSessions.length} selected session{ selectedSessions.length > 1 ? ('s') : null }.)} - -
    - - -
    - { pairedServerConnection === 'ok' && ()} - -
    -
    -
    -
    + { selectedSessions.length > 0 && + ({ selectedSessions.length} selected session{ selectedSessions.length > 1 ? ('s') : null }.)} + + + + +
    + { pairedServerConnection === 'ok' && ()} + +
    +
    + ); }; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index af79e4a0ee..61c6f4568c 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -10,8 +10,11 @@ import { SettingsMenuButton } from '../../components/SettingsMenu'; import { openExternalLink } from '../../components/ExternalLink'; import Switch from './Switch'; import getVersion from '../../utils/getVersion'; +import useOnlineStatus from '../../hooks/useOnlineStatus'; const HeaderSection = () => { + const onlineStatus = useOnlineStatus(); + const [appVersion, setAppVersion] = useState('0.0.0'); const showGettingStarted = useSelector(state => state.deviceSettings.showGettingStarted); const dispatch = useDispatch(); @@ -87,17 +90,20 @@ const HeaderSection = () => {
    - + { !pairedServer ? ( ) : - () + () }
    diff --git a/src/ducks/modules/session.js b/src/ducks/modules/session.js index 42db6e416a..2f2a613240 100644 --- a/src/ducks/modules/session.js +++ b/src/ducks/modules/session.js @@ -42,7 +42,7 @@ const setSession = id => (dispatch, getState) => { const endSession = (alsoDelete = false, markAsFinished = false) => (dispatch, getState) => { if (markAsFinished) { const { activeSessionId } = getState(); - dispatch(SessionsActions.finishSession(activeSessionId)); + dispatch(SessionsActions.setSessionFinished(activeSessionId)); } dispatch({ diff --git a/src/ducks/modules/sessions.js b/src/ducks/modules/sessions.js index a6efc800d4..d7c1767a03 100644 --- a/src/ducks/modules/sessions.js +++ b/src/ducks/modules/sessions.js @@ -6,7 +6,8 @@ import networkReducer, { actionTypes as networkActionTypes, actionCreators as ne const ADD_SESSION = 'ADD_SESSION'; -const FINISH_SESSION = 'FINISH_SESSION'; +const SET_SESSION_FINISHED = 'SET_SESSION_FINISHED'; +const SET_SESSION_EXPORTED = 'SET_SESSION_EXPORTED'; const LOAD_SESSION = 'LOAD_SESSION'; const UPDATE_PROMPT = 'UPDATE_PROMPT'; const UPDATE_STAGE = 'UPDATE_STAGE'; @@ -46,6 +47,9 @@ const getReducer = network => ...state, [action.sessionId]: withTimestamp({ ...state[action.sessionId], + // Reset finished and exported state if network changes + finishedAt: null, + exportedAt: null, network: network(state[action.sessionId].network, action), }), }; @@ -61,7 +65,7 @@ const getReducer = network => startedAt: Date.now(), }), }; - case FINISH_SESSION: + case SET_SESSION_FINISHED: return { ...state, [action.sessionId]: withTimestamp({ @@ -69,6 +73,14 @@ const getReducer = network => finishedAt: Date.now(), }), }; + case SET_SESSION_EXPORTED: + return { + ...state, + [action.sessionId]: withTimestamp({ + ...state[action.sessionId], + exportedAt: Date.now(), + }), + }; case LOAD_SESSION: return state; case UPDATE_PROMPT: @@ -410,8 +422,13 @@ function removeSession(id) { }; } -const finishSession = id => ({ - type: FINISH_SESSION, +const setSessionFinished = id => ({ + type: SET_SESSION_FINISHED, + sessionId: id, +}); + +const setSessionExported = id => ({ + type: SET_SESSION_EXPORTED, sessionId: id, }); @@ -434,12 +451,14 @@ const actionCreators = { updateCaseId, updateStageState, removeSession, - finishSession, + setSessionFinished, + setSessionExported, }; const actionTypes = { ADD_SESSION, - FINISH_SESSION, + SET_SESSION_FINISHED, + SET_SESSION_EXPORTED, LOAD_SESSION, UPDATE_PROMPT, UPDATE_STAGE, diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 93aa5e23be..c152fb0bf0 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -714,4 +714,24 @@ } } } +} + +.dialog { + .export-error-list { + margin: 0; + padding: 0; + + li { + list-style: none; + display: flex; + align-items: center; + margin: 0.75rem; + + .icon { + height: unit(3); + width: unit(3); + margin-right: unit(2); + } + } + } } \ No newline at end of file diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 25747cdd52..d99b052573 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -348,6 +348,8 @@ class ApiClient { this.emit('error', ProgressMessages.NoResponseMessage); } }).then(() => { + this.emit('session-exported', sessionList[index].sessionVariables.sessionId); + if (!cancelled) { this.emit('update', ProgressMessages.ExportSession(index + 1, sessionList.length)); } diff --git a/src/utils/exportProcess.js b/src/utils/exportProcess.js index e4540cb915..f88430f068 100644 --- a/src/utils/exportProcess.js +++ b/src/utils/exportProcess.js @@ -1,8 +1,9 @@ import React from 'react'; import uuid from 'uuid'; -import { ProgressBar, Scroller, Spinner, Icon } from '@codaco/ui'; +import { ProgressBar, Spinner, Icon } from '@codaco/ui'; import { store } from '../ducks/store'; import { actionCreators as toastActions, toastTypes } from '../ducks/modules/toasts'; +import { actionCreators as sessionsActions } from '../ducks/modules/sessions'; import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; import ApiClient from './ApiClient'; import FileExportManager from './network-exporters/src/FileExportManager'; @@ -10,6 +11,22 @@ import FileExportManager from './network-exporters/src/FileExportManager'; const dispatch = store.dispatch; const getState = store.getState; +const showExportBeginToast = (id) => { + dispatch(toastActions.addToast({ + id, + type: toastTypes.info, + title: 'Exporting interviews...', + CustomIcon: (), + autoDismiss: false, + content: ( + +

    Starting export...

    + +
    + ), + })); +}; + const showCancellationToast = () => { dispatch(toastActions.addToast({ type: toastTypes.warning, @@ -53,19 +70,7 @@ export const exportToFile = (sessionList) => { fileExportManager.on('begin', () => { // Create a toast to show the status as it updates - dispatch(toastActions.addToast({ - id: toastUUID, - type: toastTypes.info, - title: 'Exporting interviews...', - CustomIcon: (), - autoDismiss: false, - content: ( - -

    Starting export...

    - -
    - ), - })); + showExportBeginToast(toastUUID); }); fileExportManager.on('update', ({ statusText, progress }) => { @@ -80,10 +85,14 @@ export const exportToFile = (sessionList) => { }); fileExportManager.on('error', (error) => { + // If this is the first error, update the toast type to 'warning' + if (errors.length === 0) { + dispatch(toastActions.updateToast(toastUUID, { + type: 'warning', + })); + } + errors.push(error); - dispatch(toastActions.updateToast(toastUUID, { - type: 'warning', - })); }); fileExportManager.on('finished', () => { @@ -105,9 +114,7 @@ export const exportToFile = (sessionList) => { received.

    Errors: - -
      {errorList}
    -
    +
      {errorList}
    ), })); @@ -153,30 +160,104 @@ export const exportToFile = (sessionList) => { }; export const exportToServer = (sessionList) => { + const toastUUID = uuid(); + const errors = []; + const pairedServer = getState().pairedServer; const client = new ApiClient(pairedServer); client.addTrustedCert(); client.on('begin', () => { - dispatch(sessionExportStart()); + showExportBeginToast(toastUUID); }); client.on('update', ({ statusText, progress }) => { - dispatch(sessionExportUpdate(statusText, progress)); + dispatch(toastActions.updateToast(toastUUID, { + content: ( + +

    {statusText}

    + +
    + ), + })); }); + client.on('session-exported', sessionId => { + console.log('got session-exported', sessionId); + dispatch(sessionsActions.setSessionExported(sessionId)); + }); + + client.on('error', (error) => { - dispatch(sessionExportError(error)); + // If this is the first error, update the toast type to 'warning' + if (errors.length === 0) { + dispatch(toastActions.updateToast(toastUUID, { + type: 'warning', + })); + } + console.log('error', error); + errors.push(error); }); - client.on('finished', ({ statusText, progress }) => { - dispatch(sessionExportFinish(statusText, progress)); + client.on('finished', () => { + dispatch(toastActions.removeToast(toastUUID)); + + if (errors.length > 0) { + const errorList = errors.map((error, index) => (
  • {error}
  • )); + + dispatch(dialogActions.openDialog({ + type: 'Warning', + title: 'Errors encountered during export', + canCancel: false, + message: ( + +

    + Your export completed, but non-fatal errors were encountered during the process. This + may mean that not all sessions were transferred to Server. + Review the details of these errors below, and ensure that you check the data you + received. +

    + Errors: +
      {errorList}
    +
    + ), + })); + + return; + } + + dispatch(toastActions.addToast({ + type: toastTypes.success, + title: 'Export Complete!', + autoDismiss: true, + content: ( + +

    Your sessions were exported successfully.

    +
    + ), + })); }); const exportPromise = client.exportSessions(sessionList); + + // Attatch the dismisshandler to the toast not that we have exportPromise defined. + dispatch(toastActions.updateToast(toastUUID, { + dismissHandler: () => { + showCancellationToast(); + exportPromise.abort(); + }, + })); + exportPromise.catch((error) => { - dispatch(sessionExportFatalError(error)); + // Close the progress toast + dispatch(toastActions.removeToast(toastUUID)); + dispatch({ + type: 'SESSION_EXPORT_FATAL_ERROR', + error, + }); + + exportPromise.abort(); }); return exportPromise; diff --git a/src/utils/network-exporters b/src/utils/network-exporters index 76d4751fee..8b21890af2 160000 --- a/src/utils/network-exporters +++ b/src/utils/network-exporters @@ -1 +1 @@ -Subproject commit 76d4751feec800c3e182680386289915d6058969 +Subproject commit 8b21890af2170f7d889e5475b5fa3723d1d7ed18 From 821babecbcc381d815f83b447b9d02dedb575d5f Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 22 Sep 2020 13:58:41 +0100 Subject: [PATCH 41/71] implement getLastActiveProtocol selector --- package-lock.json | 6 +- package.json | 4 +- src/components/Cards/SessionCard.js | 41 ++- src/components/NewFilterableListWrapper.js | 1 - .../StartScreen/DataExportSection.js | 72 ++-- .../StartScreen/InterviewSection.js | 19 +- .../StartScreen/ResumeSessionPicker.js | 2 + src/ducks/modules/importProtocolold.js | 311 ------------------ src/ducks/modules/sessions.js | 4 +- src/ducks/modules/toasts.js | 7 - src/ducks/modules/toastsEpic.js | 20 +- src/selectors/protocol.js | 35 +- src/utils/ApiClient.js | 3 +- src/utils/exportProcess.js | 39 ++- src/utils/protocol/importProtocol.js | 75 ++++- 15 files changed, 222 insertions(+), 417 deletions(-) delete mode 100644 src/ducks/modules/importProtocolold.js diff --git a/package-lock.json b/package-lock.json index f24ca4470b..acef9bc33c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1197,9 +1197,9 @@ } }, "@codaco/ui": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@codaco/ui/-/ui-3.5.1.tgz", - "integrity": "sha512-zZTySyqIB+Gn2AIrJEyeOIvMnBp9R2GaAQe07rYrVQ0UmG865zVw5NTcJ2OiKXbNUaUpI0ynmUgNHclmoFd+iQ==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/@codaco/ui/-/ui-3.7.0.tgz", + "integrity": "sha512-Mi1MuS/V1Q21E7yRHz4oDTIEYTXCyUbqJgTcn2K59tZIEi+rHr8cDykJsKJ4nZAnBy1hDlIjmPJCtwbd3Rs8yQ==", "dev": true, "requires": { "animejs": "^2.2.0", diff --git a/package.json b/package.json index 93d3bc1dd2..c682fb73a2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@babel/preset-env": "^7.10.4", "@babel/preset-react": "^7.10.1", "@codaco/eslint-plugin-spellcheck": "0.0.14", - "@codaco/ui": "^3.5.1", + "@codaco/ui": "^3.7.0", "@zippytech/sorty": "^2.0.0", "ajv": "^6.5.4", "animejs": "^2.2.0", @@ -218,7 +218,7 @@ "description": "Network Canvas interview protocol", "icon": "file", "role": "Viewer" - }, + }, "win": { "target": "nsis", "certificateSha1": "512E5573B1961B0ADCAFB21288935F8A3E6E656F" diff --git a/src/components/Cards/SessionCard.js b/src/components/Cards/SessionCard.js index dc075e17f3..023465620d 100644 --- a/src/components/Cards/SessionCard.js +++ b/src/components/Cards/SessionCard.js @@ -1,36 +1,44 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { get } from 'lodash'; import { SessionCard as UISessionCard } from '@codaco/ui/lib/components/Cards'; import { useSelector, useDispatch } from 'react-redux'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; -/** - * Renders a card with details. - */ -const SessionCard = (props) => { - const { - sessionUUID, - } = props; +const SessionCard = ({ + sessionUUID, +}) => { + const sessions = useSelector(state => state.sessions); + const session = sessions[sessionUUID]; + const installedProtocols = useSelector(state => state.installedProtocols); - const dispatch = useDispatch(); - const setSession = session => dispatch(sessionActions.setSession(session)); + if (!session) { return null; } - const session = useSelector(state => state.sessions[sessionUUID]); - const protocol = useSelector(state => get(state.installedProtocols, [session.protocolUID])); - const progress = Math.round( - (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, - ); + const dispatch = useDispatch(); + const setSession = sessionUID => dispatch(sessionActions.setSession(sessionUID)); const { caseId, startedAt, updatedAt, exportedAt, + finishedAt, + protocolUID, + stageIndex, } = session; + const protocol = installedProtocols[protocolUID]; + + const { + name, + stages, + } = protocol; + + const progress = Math.round( + (oneBasedIndex(stageIndex) / oneBasedIndex(stages.length)) * 100, + ); + const onClickLoadSession = (event) => { event.preventDefault(); setSession(sessionUUID); @@ -42,7 +50,8 @@ const SessionCard = (props) => { startedAt={startedAt} updatedAt={updatedAt} exportedAt={exportedAt} - protocolName={protocol.name} + finishedAt={finishedAt} + protocolName={name} progress={progress} onClickHandler={onClickLoadSession} /> diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index a88b127871..9eefe56790 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -161,7 +161,6 @@ const NewFilterableListWrapper = (props) => { } - ) } diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js index d2fc7d540b..cf78e4255f 100644 --- a/src/containers/StartScreen/DataExportSection.js +++ b/src/containers/StartScreen/DataExportSection.js @@ -1,11 +1,13 @@ import React, { useState } from 'react'; import { get } from 'lodash'; -import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; +import { motion } from 'framer-motion'; import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@codaco/ui'; import { SessionCard } from '@codaco/ui/lib/components/Cards'; import { exportToFile, exportToServer } from '../../utils/exportProcess'; import { Section } from '.'; +import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; +import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; import Switch from './Switch'; import { NewFilterableListWrapper } from '../../components'; @@ -14,15 +16,37 @@ import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; const DataExportSection = () => { + const sessions = useSelector(state => state.sessions); + + if (Object.keys(sessions).length === 0) { return null; } + const [selectedSessions, setSelectedSessions] = useState([]); const pairedServer = useSelector(state => state.pairedServer); const pairedServerConnection = useServerConnectionStatus(pairedServer); - - const sessions = useSelector(state => state.sessions); const installedProtocols = useSelector(state => state.installedProtocols); const dispatch = useDispatch(); + const deleteSession = id => dispatch(sessionsActions.removeSession(id)); + const openDialog = dialog => dispatch(dialogActions.openDialog(dialog)); + + const handleDeleteSessions = () => { + openDialog({ + type: 'Warning', + title: `Delete ${selectedSessions.length} Interview Session${selectedSessions.length > 1 ? 's' : ''}?`, + confirmLabel: 'Permanently Delete', + onConfirm: () => { + selectedSessions.map(session => deleteSession(session)); + setSelectedSessions([]); + }, + message: ( +

    + This action will delete the selected interview data and cannot be undone. + Are you sure you want to continue? +

    + ), + }); + }; const toggleSelectAll = () => { if ((Object.keys(sessions).length !== selectedSessions.length)) { @@ -49,16 +73,17 @@ const DataExportSection = () => { }; const formattedSessions = [...Object.keys(sessions)].map((sessionUUID) => { - const session = useSelector(state => state.sessions[sessionUUID]); + const session = sessions[sessionUUID]; const { caseId, startedAt, updatedAt, + finishedAt, exportedAt, } = session; - const protocol = useSelector(state => get(state.installedProtocols, [session.protocolUID])); + const protocol = get(installedProtocols, [session.protocolUID]); const progress = Math.round( (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, ); @@ -67,6 +92,7 @@ const DataExportSection = () => { caseId, progress, startedAt, + finishedAt, updatedAt, exportedAt, key: sessionUUID, @@ -77,23 +103,11 @@ const DataExportSection = () => { }; }); - const exportSessionsToFile = () => { - exportToFile(selectedSessions.map((session) => { - const sessionProtocol = - installedProtocols[sessions[session].protocolUID]; - - return asNetworkWithSessionVariables( - session, - sessions[session], - sessionProtocol, - ); - })); - }; + const exportSessions = (toServer = false) => { + const exportFunction = toServer ? exportToServer : exportToFile; - const exportSessionsToServer = () => { - exportToServer(selectedSessions.map((session) => { - const sessionProtocol = - installedProtocols[sessions[session].protocolUID]; + exportFunction(selectedSessions.map((session) => { + const sessionProtocol = installedProtocols[sessions[session].protocolUID]; return asNetworkWithSessionVariables( session, @@ -110,8 +124,9 @@ const DataExportSection = () => {

    Export & Manage Interview Data

    - Select one or more interview sessions by tapping then, and then delete or export - them using the buttons provided. + Select one or more interview sessions by tapping them, and then delete or export + using the buttons provided. Remember that you can change export options from the + settings menu, which can be opened from the header at the top of this screen. { 0 + && (Object.keys(sessions).length === selectedSessions.length) + } onChange={toggleSelectAll} /> { selectedSessions.length > 0 && @@ -149,10 +167,10 @@ const DataExportSection = () => { - +
    - { pairedServerConnection === 'ok' && ()} - + { pairedServerConnection === 'ok' && ()} +
    diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 691592c76f..4037c5d12d 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -6,28 +6,26 @@ import { ProtocolCard, SessionCard } from '../../components/Cards'; import { actionCreators as sessionActions } from '../../ducks/modules/sessions'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; import { getLastActiveSession } from '../../selectors/session'; +import { getLastActiveProtocol } from '../../selectors/protocol'; import NewSessionOverlay from './NewSessionOverlay'; import StackButton from '../../components/StackButton'; import ResumeSessionPicker from './ResumeSessionPicker'; import StartInterviewPicker from './StartInterviewPicker'; const InterviewSection = () => { + const installedProtocols = useSelector(state => state.installedProtocols); + const sessions = useSelector(state => state.sessions); + + const lastActiveSession = useSelector(state => getLastActiveSession(state)); + const lastActiveProtocol = useSelector(state => getLastActiveProtocol(state)); + const dispatch = useDispatch(); const addSession = (caseId, protocol) => dispatch(sessionActions.addSession(caseId, protocol)); const toggleUIOverlay = overlay => dispatch(uiActions.toggle(overlay)); - const installedProtocols = useSelector(state => state.installedProtocols); - const lastActiveSession = useSelector(state => getLastActiveSession(state)); - const sessions = useSelector(state => state.sessions); const showResumeSessionPicker = useSelector(state => state.ui.showResumeSessionPicker); const showStartInterviewPicker = useSelector(state => state.ui.showStartInterviewPicker); - - const lastActiveProtocol = { - ...installedProtocols[Object.keys(installedProtocols)[0]], - protocolUID: Object.keys(installedProtocols)[0], - }; - const [showNewSessionOverlay, setShowNewSessionOverlay] = useState(false); const [selectedProtocol, setSelectedProtocol] = useState(null); @@ -46,8 +44,6 @@ const InterviewSection = () => { setSelectedProtocol(protocolUID); }; - const sessionCardClickHandler = () => {}; - if (Object.keys(installedProtocols).length === 0 && Object.keys(sessions).length === 0) { return null; } @@ -118,7 +114,6 @@ const InterviewSection = () => {

    Resume an Interview

    diff --git a/src/containers/StartScreen/ResumeSessionPicker.js b/src/containers/StartScreen/ResumeSessionPicker.js index 56e9b4c15b..45ec1c2304 100644 --- a/src/containers/StartScreen/ResumeSessionPicker.js +++ b/src/containers/StartScreen/ResumeSessionPicker.js @@ -36,6 +36,8 @@ const ResumeSessionPicker = ({ caseId: session.caseId, startedAt: session.startedAt, updatedAt: session.updatedAt, + finishedAt: session.finishedAt, + exportedAt: session.exportedAt, protocolName: protocol.name, progress, onClickHandler: () => handleSessionCardClick(sessionUUID), diff --git a/src/ducks/modules/importProtocolold.js b/src/ducks/modules/importProtocolold.js deleted file mode 100644 index caecd3ac9b..0000000000 --- a/src/ducks/modules/importProtocolold.js +++ /dev/null @@ -1,311 +0,0 @@ -import React from 'react'; -import { ProgressBar, Spinner } from '@codaco/ui'; -import { CancellationError } from 'builder-util-runtime'; -import { removeDirectory } from '../../utils/filesystem'; -import downloadProtocol from '../../utils/protocol/downloadProtocol'; -import extractProtocol from '../../utils/protocol/extractProtocol'; -import parseProtocol from '../../utils/protocol/parseProtocol'; -import checkExistingProtocol, { moveToExistingProtocol } from '../../utils/protocol/checkExistingProtocol'; -import { actionCreators as toastActions, toastTypes } from './toasts'; -import protocolPath from '../../utils/protocol/protocolPath'; - -const DOWNLOAD_PROTOCOL = 'DOWNLOAD_PROTOCOL'; -const EXTRACT_PROTOCOL = 'EXTRACT_PROTOCOL'; -const PARSE_PROTOCOL = 'PARSE_PROTOCOL'; -const CHECK_EXISTING_PROTOCOL = 'CHECK_EXISTING_PROTOCOL'; -const IMPORT_PROTOCOL_START = 'IMPORT_PROTOCOL_START'; -const IMPORT_PROTOCOL_FAILED = 'IMPORT_PROTOCOL_FAILED'; -const IMPORT_PROTOCOL_COMPLETE = 'IMPORT_PROTOCOL_COMPLETE'; -const RESET_IMPORT = 'RESET_IMPORT'; - -export const initialState = { - status: 'inactive', - step: 0, -}; - -export default function reducer(state = initialState, action = {}) { - switch (action.type) { - case IMPORT_PROTOCOL_FAILED: - case RESET_IMPORT: - return initialState; - case CHECK_EXISTING_PROTOCOL: - return { - statusText: 'Checking for previous installation...', - step: 1, - }; - case IMPORT_PROTOCOL_START: - return { - statusText: 'Initializing protocol import...', - step: 2, - }; - case DOWNLOAD_PROTOCOL: - return { - statusText: 'Downloading protocol...', - step: 3, - }; - case EXTRACT_PROTOCOL: - return { - statusText: 'Extracting protocol to temporary storage...', - step: 4, - }; - case PARSE_PROTOCOL: - return { - statusText: 'Parsing and validating protocol...', - step: 5, - }; - case IMPORT_PROTOCOL_COMPLETE: - return { - statusText: 'Protocol imported successfully!', - step: 6, - }; - default: - return state; - } -} - -function downloadProtocolAction() { - return { - type: DOWNLOAD_PROTOCOL, - }; -} - -function extractProtocolAction() { - return { - type: EXTRACT_PROTOCOL, - }; -} - -function parseProtocolAction() { - return { - type: PARSE_PROTOCOL, - }; -} - -function checkExistingProtocolAction() { - return { - type: CHECK_EXISTING_PROTOCOL, - }; -} - -function importProtocolStartAction() { - return { - type: IMPORT_PROTOCOL_START, - }; -} - -function importProtocolFailedAction(error) { - return { - type: IMPORT_PROTOCOL_FAILED, - error, - }; -} - -function importProtocolCompleteAction(protocolData) { - return { - type: IMPORT_PROTOCOL_COMPLETE, - protocolData, - }; -} - -function resetImportAction() { - return { - type: RESET_IMPORT, - }; -} - -const cleanUpProtocol = (uid) => { - if (uid) { - const cancelledDir = protocolPath(uid); - removeDirectory(cancelledDir) - .catch(() => {}); // best effort - } -}; - -const cancelledImport = () => Promise.reject(new CancellationError('Import cancelled.')); - -const filenameFromURI = uri => - decodeURIComponent(uri.split('/').pop().split('#')[0].split('?')[0]); - -const filenameFromPath = path => path.split(/.*[/|\\]/)[1]; - -const protocolNameFromFilename = (filename) => { - const PROTOCOL_EXTENSION = '.netcanvas'; - return filename.slice(0, -PROTOCOL_EXTENSION.length); -}; - -const catchError = error => Promise.reject(error); - -const importProtocolFromURI = (uri, usePairedServer) => (dispatch, getState) => { - let pairedServer; - let protocolUid; - let previousUid; - const filename = filenameFromURI(uri); - const protocolName = protocolNameFromFilename(filename); - - if (usePairedServer) { - pairedServer = getState().pairedServer; - } - - return new Promise((resolve) => { - dispatch(checkExistingProtocolAction()); - - checkExistingProtocol(dispatch, getState(), protocolName) - .then((existingUid) => { - previousUid = existingUid; - dispatch(importProtocolStartAction()); - dispatch(toastActions.addToast({ - id: previousUid, - type: toastTypes.info, - title: 'Importing Protocol...', - autoDismiss: false, - content: ( - - - - ), - })); - dispatch(downloadProtocolAction()); - return downloadProtocol(uri, pairedServer); - }) - .then((tempLocation) => { - if (getState().importProtocol.step === 0) return cancelledImport(); - dispatch(extractProtocolAction()); - dispatch(toastActions.updateToast(previousUid, { - type: toastTypes.info, - title: 'Extracting to temporary storage...', - content: ( - - - - ), - })); - return extractProtocol(tempLocation); - }, catchError) - .then((protocolLocation) => { - protocolUid = protocolLocation; - if (getState().importProtocol.step === 0) return cancelledImport(); - dispatch(parseProtocolAction()); - dispatch(toastActions.updateToast(previousUid, { - type: toastTypes.info, - title: 'Validating protocol...', - content: ( - - - - ), - })); - return parseProtocol(protocolLocation, protocolName); - }, catchError) - .then((protocolContent) => { - if (getState().importProtocol.step === 0) return cancelledImport(); - if (previousUid) { - return moveToExistingProtocol(previousUid, protocolContent); - } - return protocolContent; - }) - .then((protocolContent) => { - if (getState().importProtocol.step === 0) return cancelledImport(); - dispatch(importProtocolCompleteAction(protocolContent)); - dispatch(toastActions.removeToast(previousUid)); - dispatch(toastActions.addToast({ - type: toastTypes.success, - title: 'Finished!', - autoDismiss: true, - content: ( - -

    Protocol installed successfully.

    -
    - ), - })); - return resolve(); - }, catchError) - .catch( - (error) => { - if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files - if (error instanceof CancellationError) { - dispatch(resetImportAction()); - } else { - dispatch(importProtocolFailedAction(error)); - } - }, - ); - }); -}; - -const importProtocolFromFile = (filePath, name) => (dispatch, getState) => { - let protocolUid; - let previousUid; - - const filename = filenameFromPath(filePath); - const protocolName = protocolNameFromFilename(name || filename); - - dispatch(checkExistingProtocolAction()); - return checkExistingProtocol(dispatch, getState(), protocolName) - .then((existingUid) => { - previousUid = existingUid; - dispatch(importProtocolStartAction()); - dispatch(extractProtocolAction()); - return extractProtocol(filePath); - }) - .then((protocolLocation) => { - protocolUid = protocolLocation; - if (getState().importProtocol.step === 0) return cancelledImport(protocolLocation); - dispatch(parseProtocolAction()); - return parseProtocol(protocolLocation, protocolName); - }, catchError) - .then((protocolContent) => { - if (getState().importProtocol.step === 0) return cancelledImport(protocolContent.uid); - if (previousUid) { - return moveToExistingProtocol(previousUid, protocolContent); - } - return protocolContent; - }) - .then((protocolContent) => { - if (getState().importProtocol.step === 0) return cancelledImport(protocolContent.uid); - return dispatch(importProtocolCompleteAction(protocolContent)); - }, catchError) - .catch( - (error) => { - if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files - if (error instanceof CancellationError) { - dispatch(resetImportAction()); - } else { - dispatch(importProtocolFailedAction(error)); - } - }, - ); -}; - -const helpers = { - filenameFromURI, -}; - -const actionCreators = { - importProtocolFromURI, - importProtocolFromFile, - parseProtocol: parseProtocolAction, - extractProtocol: extractProtocolAction, - downloadProtocol: downloadProtocolAction, - checkExistingProtocol: checkExistingProtocolAction, - importProtocolStart: importProtocolStartAction, - importProtocolComplete: importProtocolCompleteAction, - importProtocolFailed: importProtocolFailedAction, - resetImportProtocol: resetImportAction, -}; - -const actionTypes = { - EXTRACT_PROTOCOL, - DOWNLOAD_PROTOCOL, - PARSE_PROTOCOL, - CHECK_EXISTING_PROTOCOL, - IMPORT_PROTOCOL_START, - IMPORT_PROTOCOL_FAILED, - IMPORT_PROTOCOL_COMPLETE, - RESET_IMPORT, -}; - -export { - actionCreators, - actionTypes, - helpers, -}; diff --git a/src/ducks/modules/sessions.js b/src/ducks/modules/sessions.js index d7c1767a03..2d0a692f97 100644 --- a/src/ducks/modules/sessions.js +++ b/src/ducks/modules/sessions.js @@ -76,10 +76,10 @@ const getReducer = network => case SET_SESSION_EXPORTED: return { ...state, - [action.sessionId]: withTimestamp({ + [action.sessionId]: { ...state[action.sessionId], exportedAt: Date.now(), - }), + }, }; case LOAD_SESSION: return state; diff --git a/src/ducks/modules/toasts.js b/src/ducks/modules/toasts.js index 02c1f4674b..79af85f3df 100644 --- a/src/ducks/modules/toasts.js +++ b/src/ducks/modules/toasts.js @@ -71,16 +71,9 @@ const actionTypes = { REMOVE_TOAST, }; -const toastTypes = { - info: 'info', - warning: 'warning', - success: 'success', -}; - export { actionCreators, actionTypes, - toastTypes, }; export default reducer; diff --git a/src/ducks/modules/toastsEpic.js b/src/ducks/modules/toastsEpic.js index 806dbc9637..82915a4452 100644 --- a/src/ducks/modules/toastsEpic.js +++ b/src/ducks/modules/toastsEpic.js @@ -2,15 +2,9 @@ import React from 'react'; import { combineEpics } from 'redux-observable'; import { filter, map } from 'rxjs/operators'; -import { actionCreators as toastActions, toastTypes } from './toasts'; +import { actionCreators as toastActions } from './toasts'; import { actionTypes as serverActionTypes } from './pairedServer'; -/** - * Online/offline status change - * Server pairing - * MAKE SURE THESE DO NOT SHOW WITHIN INTERVIEW - */ - const triggerToastActions = [ serverActionTypes.SET_SERVER, ]; @@ -21,13 +15,13 @@ const toastsEpic = action$ => action$.pipe( switch (action.type) { case serverActionTypes.SET_SERVER: { return toastActions.addToast({ - type: toastTypes.success, + type: 'success', title: 'Pairing complete!', - content: ( - -

    You have successfully paired with Server.

    -
    - ), + content: + (

    + You have successfully paired with Server. You may now fetch protocols + and upload data. +

    ), }); } default: diff --git a/src/selectors/protocol.js b/src/selectors/protocol.js index 7859e27708..0eaaa46815 100644 --- a/src/selectors/protocol.js +++ b/src/selectors/protocol.js @@ -1,9 +1,11 @@ import crypto from 'crypto'; import uuid from 'uuid/v4'; +import { orderBy, values, mapValues } from 'lodash'; import { createSelector } from 'reselect'; import { createDeepEqualSelector } from './utils'; import { isPreview } from '../utils/Environment'; -import { getActiveSession } from './session'; +import { getActiveSession, getLastActiveSession } from './session'; +import { entityAttributesProperty } from '../ducks/modules/network'; const DefaultFinishStage = { // `id` is used as component key; must be unique from user input @@ -32,6 +34,37 @@ export const getCurrentSessionProtocol = createSelector( }, ); +// Use the protocol associated with the last active session, unless there is a protocol with +// an `installationDate` that is more recent. +export const getLastActiveProtocol = (state) => { + const installedProtocols = getInstalledProtocols(state); + + if (Object.keys(installedProtocols).length === 0) { + return null; + } + + const lastActiveSession = getLastActiveSession(state); + + const protocolsCollection = values(mapValues(installedProtocols, (protocol, protocolUID) => ({ + protocolUID, + ...protocol, + }))); + + const lastInstalledProtocol = orderBy(protocolsCollection, ['installationDate'], ['desc'])[0]; + + if ( + lastActiveSession[entityAttributesProperty].updatedAt && // Last active session exists + lastActiveSession[entityAttributesProperty].updatedAt > lastInstalledProtocol.installationDate + ) { + return { + ...installedProtocols[lastActiveSession[entityAttributesProperty].protocolUID], + protocolUID: lastActiveSession[entityAttributesProperty].protocolUID, + }; + } + + return lastInstalledProtocol; +}; + export const getActiveProtocolName = createSelector( getCurrentSessionProtocol, protocol => protocol && protocol.name, diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index d99b052573..9b809b915f 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -328,6 +328,7 @@ class ApiClient { await previousPromise; return this.exportSession(nextSession) + .then(() => this.emit('session-exported', sessionList[index].sessionVariables.sessionId)) .catch((error) => { if (axios.isCancel(error)) { return; @@ -348,8 +349,6 @@ class ApiClient { this.emit('error', ProgressMessages.NoResponseMessage); } }).then(() => { - this.emit('session-exported', sessionList[index].sessionVariables.sessionId); - if (!cancelled) { this.emit('update', ProgressMessages.ExportSession(index + 1, sessionList.length)); } diff --git a/src/utils/exportProcess.js b/src/utils/exportProcess.js index f88430f068..5cd3d9465d 100644 --- a/src/utils/exportProcess.js +++ b/src/utils/exportProcess.js @@ -1,8 +1,9 @@ import React from 'react'; import uuid from 'uuid'; +import { batch } from 'react-redux'; import { ProgressBar, Spinner, Icon } from '@codaco/ui'; import { store } from '../ducks/store'; -import { actionCreators as toastActions, toastTypes } from '../ducks/modules/toasts'; +import { actionCreators as toastActions } from '../ducks/modules/toasts'; import { actionCreators as sessionsActions } from '../ducks/modules/sessions'; import { actionCreators as dialogActions } from '../ducks/modules/dialogs'; import ApiClient from './ApiClient'; @@ -14,7 +15,7 @@ const getState = store.getState; const showExportBeginToast = (id) => { dispatch(toastActions.addToast({ id, - type: toastTypes.info, + type: 'info', title: 'Exporting interviews...', CustomIcon: (), autoDismiss: false, @@ -29,7 +30,7 @@ const showExportBeginToast = (id) => { const showCancellationToast = () => { dispatch(toastActions.addToast({ - type: toastTypes.warning, + type: 'warning', title: 'Export cancelled', content: ( @@ -64,6 +65,7 @@ export const exportToFile = (sessionList) => { }; const errors = []; + const succeeded = []; const toastUUID = uuid(); const fileExportManager = new FileExportManager(exportOptions); @@ -84,6 +86,10 @@ export const exportToFile = (sessionList) => { })); }); + fileExportManager.on('session-exported', (sessionId) => { + succeeded.push(sessionId); + }); + fileExportManager.on('error', (error) => { // If this is the first error, update the toast type to 'warning' if (errors.length === 0) { @@ -98,6 +104,13 @@ export const exportToFile = (sessionList) => { fileExportManager.on('finished', () => { dispatch(toastActions.removeToast(toastUUID)); + if (succeeded.length > 0) { + batch(() => { + succeeded.forEach(successfulExport => + dispatch(sessionsActions.setSessionExported(successfulExport))); + }); + } + if (errors.length > 0) { const errorList = errors.map((error, index) => (
  • {error}
  • )); @@ -123,7 +136,7 @@ export const exportToFile = (sessionList) => { } dispatch(toastActions.addToast({ - type: toastTypes.success, + type: 'success', title: 'Export Complete!', autoDismiss: true, content: ( @@ -162,6 +175,7 @@ export const exportToFile = (sessionList) => { export const exportToServer = (sessionList) => { const toastUUID = uuid(); const errors = []; + const succeeded = []; const pairedServer = getState().pairedServer; @@ -183,12 +197,10 @@ export const exportToServer = (sessionList) => { })); }); - client.on('session-exported', sessionId => { - console.log('got session-exported', sessionId); - dispatch(sessionsActions.setSessionExported(sessionId)); + client.on('session-exported', (sessionId) => { + succeeded.push(sessionId); }); - client.on('error', (error) => { // If this is the first error, update the toast type to 'warning' if (errors.length === 0) { @@ -196,13 +208,20 @@ export const exportToServer = (sessionList) => { type: 'warning', })); } - console.log('error', error); + errors.push(error); }); client.on('finished', () => { dispatch(toastActions.removeToast(toastUUID)); + if (succeeded.length > 0) { + batch(() => { + succeeded.forEach(successfulExport => + dispatch(sessionsActions.setSessionExported(successfulExport))); + }); + } + if (errors.length > 0) { const errorList = errors.map((error, index) => (
  • {error}
  • )); @@ -228,7 +247,7 @@ export const exportToServer = (sessionList) => { } dispatch(toastActions.addToast({ - type: toastTypes.success, + type: 'success', title: 'Export Complete!', autoDismiss: true, content: ( diff --git a/src/utils/protocol/importProtocol.js b/src/utils/protocol/importProtocol.js index 9c559eb15b..b5d85bebcc 100644 --- a/src/utils/protocol/importProtocol.js +++ b/src/utils/protocol/importProtocol.js @@ -9,7 +9,7 @@ import downloadProtocol from '../../utils/protocol/downloadProtocol'; import extractProtocol from '../../utils/protocol/extractProtocol'; import parseProtocol from '../../utils/protocol/parseProtocol'; import checkExistingProtocol, { moveToExistingProtocol } from '../../utils/protocol/checkExistingProtocol'; -import { actionCreators as toastActions, toastTypes } from '../../ducks/modules/toasts'; +import { actionCreators as toastActions } from '../../ducks/modules/toasts'; import protocolPath from '../../utils/protocol/protocolPath'; import { PROTOCOL_EXTENSION } from '../../config'; import { isCordova, isElectron } from '../../utils/Environment'; @@ -38,7 +38,7 @@ const getState = store.getState; const showCancellationToast = () => { dispatch(toastActions.addToast({ - type: toastTypes.warning, + type: 'warning', title: 'Import cancelled', content: ( @@ -65,7 +65,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { // Create a toast to show the status as it updates dispatch(toastActions.addToast({ id: toastUUID, - type: toastTypes.info, + type: 'info', title: 'Importing Protocol...', CustomIcon: (), autoDismiss: false, @@ -138,7 +138,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { // Remove the status toast dispatch(toastActions.removeToast(toastUUID)); dispatch(toastActions.addToast({ - type: toastTypes.success, + type: 'success', title: 'Finished!', autoDismiss: true, content: ( @@ -193,23 +193,58 @@ export const beginLocalProtocolImport = () => { export const importProtocolFromFile = (filePath, name) => { + let cancelled = false; // Top-level cancelled property used to abort promise chain let protocolUid; let previousUid; const filename = filenameFromPath(filePath); const protocolName = protocolNameFromFilename(name || filename); + const toastUUID = uuid(); + + // Create a toast to show the status as it updates + dispatch(toastActions.addToast({ + id: toastUUID, + type: 'info', + title: 'Importing Protocol...', + CustomIcon: (), + autoDismiss: false, + dismissHandler: () => { + showCancellationToast(); + cancelled = true; + }, + content: ( + + + + ), + })); + return checkExistingProtocol(protocolName) .then((existingUid) => { previousUid = existingUid; - dispatch(importProtocolStartAction()); - dispatch(extractProtocolAction()); + dispatch(toastActions.updateToast(toastUUID, { + title: 'Extracting to temporary storage...', + content: ( + + + + ), + })); return extractProtocol(filePath); }) .then((protocolLocation) => { protocolUid = protocolLocation; if (cancelled) return cancelledImport(protocolLocation); - dispatch(parseProtocolAction()); + + dispatch(toastActions.updateToast(toastUUID, { + title: 'Validating protocol...', + content: ( + + + + ), + })); return parseProtocol(protocolLocation, protocolName); }, catchError) .then((protocolContent) => { @@ -221,15 +256,35 @@ export const importProtocolFromFile = (filePath, name) => { }) .then((protocolContent) => { if (cancelled) return cancelledImport(protocolContent.uid); - return dispatch(importProtocolCompleteAction(protocolContent)); + // Send the payload to installedProtocols + dispatch(installedProtocolActions.importProtocolCompleteAction(protocolContent)); + + // Remove the status toast + dispatch(toastActions.removeToast(toastUUID)); + dispatch(toastActions.addToast({ + type: 'success', + title: 'Finished!', + autoDismiss: true, + content: ( + +

    Protocol installed successfully.

    +
    + ), + })); + return Promise.resolve(); }, catchError) .catch( (error) => { - if (protocolUid) cleanUpProtocol(protocolUid); // attempt to clean up files + // Remove the status toast + dispatch(toastActions.removeToast(toastUUID)); + // attempt to clean up files + if (protocolUid) cleanUpProtocol(protocolUid); + + // If this wasn't user cancellation, dispatch an error if (!(error instanceof CancellationError)) { dispatch(installedProtocolActions.importProtocolFailedAction(error)); - } + } }, ); }; From fd077409bd3637530dd61be92b60258dfb9debbc Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 22 Sep 2020 14:04:45 +0100 Subject: [PATCH 42/71] fix crashes relating to returning too early --- src/containers/StartScreen/DataExportSection.js | 4 ++-- src/selectors/protocol.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js index cf78e4255f..bdd576c865 100644 --- a/src/containers/StartScreen/DataExportSection.js +++ b/src/containers/StartScreen/DataExportSection.js @@ -18,8 +18,6 @@ const oneBasedIndex = i => parseInt(i || 0, 10) + 1; const DataExportSection = () => { const sessions = useSelector(state => state.sessions); - if (Object.keys(sessions).length === 0) { return null; } - const [selectedSessions, setSelectedSessions] = useState([]); const pairedServer = useSelector(state => state.pairedServer); @@ -88,6 +86,8 @@ const DataExportSection = () => { (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, ); + if (Object.keys(sessions).length === 0) { return null; } + return { caseId, progress, diff --git a/src/selectors/protocol.js b/src/selectors/protocol.js index 0eaaa46815..c722aeb23d 100644 --- a/src/selectors/protocol.js +++ b/src/selectors/protocol.js @@ -53,6 +53,7 @@ export const getLastActiveProtocol = (state) => { const lastInstalledProtocol = orderBy(protocolsCollection, ['installationDate'], ['desc'])[0]; if ( + lastActiveSession[entityAttributesProperty] && lastActiveSession[entityAttributesProperty].updatedAt && // Last active session exists lastActiveSession[entityAttributesProperty].updatedAt > lastInstalledProtocol.installationDate ) { From e7c76b73cc6782e401e48d09384b738197b3c283 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 22 Sep 2020 15:42:12 +0100 Subject: [PATCH 43/71] restyle dyad census --- .../__tests__/dyad-census.test.js | 2 +- integration-tests/__tests__/ego-form.test.js | 4 +- src/behaviours/selectable.js | 67 +++++++++---------- src/components/NodeList.js | 23 +++---- .../Interfaces/DyadCensus/DyadCensus.js | 47 ++++++------- src/containers/Interfaces/DyadCensus/Pair.js | 6 +- .../StartScreen/DataExportSection.js | 4 +- src/styles/components/_prompts.scss | 3 +- src/styles/containers/_all.scss | 2 +- ..._dyad-interface.scss => _dyad-census.scss} | 30 +++++++-- 10 files changed, 100 insertions(+), 88 deletions(-) rename src/styles/containers/{_dyad-interface.scss => _dyad-census.scss} (86%) diff --git a/integration-tests/__tests__/dyad-census.test.js b/integration-tests/__tests__/dyad-census.test.js index a447528140..81328ae29b 100644 --- a/integration-tests/__tests__/dyad-census.test.js +++ b/integration-tests/__tests__/dyad-census.test.js @@ -82,6 +82,6 @@ describe('Dyad Census Interface', () => { await timelinePrevious(app); await app.client.pause(timing.long); await app.client.waitForExist('//div[@class="progress-bar__filler" and style="width: 100%;"]', 500, true); - await app.client.waitForExist('//div[@class="dyad-interface__no"]//input[@class="form-field-togglebutton__input" and value="true" and checked]', 500, true); + await app.client.waitForExist('//div[@class="dyad-census__no"]//input[@class="form-field-togglebutton__input" and value="true" and checked]', 500, true); }); }); diff --git a/integration-tests/__tests__/ego-form.test.js b/integration-tests/__tests__/ego-form.test.js index 4afd1aa1f8..1abe22cecc 100644 --- a/integration-tests/__tests__/ego-form.test.js +++ b/integration-tests/__tests__/ego-form.test.js @@ -36,7 +36,7 @@ describe('EgoForm Interface', () => { it('Allows leaving via Timeline if not changed', async () => { await goToStage(app, 'dyadcensus'); - await app.client.waitForVisible('.dyad-interface__introduction'); + await app.client.waitForVisible('.dyad-census__introduction'); }); it('Disallows leaving via Next if invalid', async () => { @@ -54,6 +54,6 @@ describe('EgoForm Interface', () => { await goToStage(app, 'dyadcensus'); await app.client.waitForVisible('.dialog--confirm'); await app.client.click('span=Discard changes'); - await app.client.waitForVisible('.dyad-interface__introduction'); + await app.client.waitForVisible('.dyad-census__introduction'); }); }); diff --git a/src/behaviours/selectable.js b/src/behaviours/selectable.js index 951c1872c1..cc27be8f25 100644 --- a/src/behaviours/selectable.js +++ b/src/behaviours/selectable.js @@ -1,49 +1,42 @@ /* eslint-disable react/no-find-dom-node */ -import React, { Component } from 'react'; -import { findDOMNode } from 'react-dom'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -export default function selectable(WrappedComponent) { - class Selectable extends Component { - componentDidMount() { - if (!this.props.allowSelect) { return; } - this.el = findDOMNode(this.node); - this.el.addEventListener('click', this.onTap, { passive: true }); - } +export const selectable = WrappedComponent => ({ + onSelected, + allowSelect = true, + ...rest +}) => { + const [selected, setSelected] = useState(false); - componentWillUnmount() { - if (this.el) { - this.el.removeEventListener('click', this.onTap); - } - } + const handleSelect = () => { + if (!allowSelect) { return; } - onTap = () => { - this.props.onSelected(); + if (!selected) { + setSelected(true); + onSelected(); + return; } - render() { - const { - allowSelect, - onSelected, - ...rest - } = this.props; + setSelected(false); + }; - if (!allowSelect) { return ; } + return ( +
    + +
    + ); +}; - return { this.node = node; }} />; - } - } +selectable.propTypes = { + onSelected: PropTypes.func, + allowSelect: PropTypes.bool, +}; - Selectable.propTypes = { - onSelected: PropTypes.func, - allowSelect: PropTypes.bool, - }; - - Selectable.defaultProps = { - onSelected: () => {}, - allowSelect: true, - }; +selectable.defaultProps = { + onSelected: null, + allowSelect: true, +}; - return Selectable; -} +export default selectable; diff --git a/src/components/NodeList.js b/src/components/NodeList.js index c2959998e2..dc3301e909 100644 --- a/src/components/NodeList.js +++ b/src/components/NodeList.js @@ -7,7 +7,7 @@ import { TransitionGroup } from 'react-transition-group'; import { getCSSVariableAsString, getCSSVariableAsNumber } from '@codaco/ui/lib/utils/CSSVariables'; import Node from '../containers/Node'; import { Node as NodeTransition } from './Transition'; -import { scrollable, selectable } from '../behaviours'; +import { scrollable } from '../behaviours'; import { DragSource, DropTarget, @@ -17,7 +17,7 @@ import { import sortOrder from '../utils/sortOrder'; import { entityPrimaryKeyProperty } from '../ducks/modules/network'; -const EnhancedNode = DragSource(selectable(Node)); +const EnhancedNode = DragSource(Node); /** * Renders a list of Nodes. @@ -81,7 +81,6 @@ class NodeList extends Component { render() { const { label, - isItemSelected, onItemClick, itemType, isOver, @@ -125,14 +124,14 @@ class NodeList extends Component { index={index} stagger={stagger} > - onItemClick(node)} - meta={() => ({ ...node, itemType })} - itemType={itemType} - {...node} - /> +
    onItemClick(node)}> + ({ ...node, itemType })} + itemType={itemType} + {...node} + /> +
    )) } @@ -147,7 +146,6 @@ NodeList.propTypes = { onItemClick: PropTypes.func, itemType: PropTypes.string, label: PropTypes.func, - isItemSelected: PropTypes.func, isOver: PropTypes.bool, isDragging: PropTypes.bool, willAccept: PropTypes.bool, @@ -161,7 +159,6 @@ NodeList.defaultProps = { items: [], hoverColor: null, label: () => (''), - isItemSelected: () => false, onItemClick: () => {}, onDrop: () => {}, itemType: 'NODE', diff --git a/src/containers/Interfaces/DyadCensus/DyadCensus.js b/src/containers/Interfaces/DyadCensus/DyadCensus.js index 73ab3d0298..21b697fd03 100644 --- a/src/containers/Interfaces/DyadCensus/DyadCensus.js +++ b/src/containers/Interfaces/DyadCensus/DyadCensus.js @@ -34,7 +34,7 @@ const optionsVariants = { }; const choiceVariants = { - show: { opacity: 1, translateY: '0%', transition: { delay: 0.25 } }, + show: { opacity: 1, translateY: '0%', transition: { delay: 0.25, type: 'spring' } }, hide: { opacity: 0, translateY: '120%' }, }; @@ -163,19 +163,19 @@ const DyadCensus = ({ }; const choiceClasses = cx( - 'dyad-interface__choice', - { 'dyad-interface__choice--invalid': !isValid }, + 'dyad-census__choice', + { 'dyad-census__choice--invalid': !isValid }, ); return ( -
    +
    { isIntroduction && -
    - -
    -
    -
    +
    +
    -
    - +
    +
    -
    +
    -
    +
    -
    +
    +
    + +
    diff --git a/src/containers/Interfaces/DyadCensus/Pair.js b/src/containers/Interfaces/DyadCensus/Pair.js index 6488718a62..8c5695a4dd 100644 --- a/src/containers/Interfaces/DyadCensus/Pair.js +++ b/src/containers/Interfaces/DyadCensus/Pair.js @@ -52,7 +52,7 @@ const Pair = ({ return ( -
    +
    { (oneBasedIndex(session.stageIndex) / oneBasedIndex(protocol.stages.length)) * 100, ); - if (Object.keys(sessions).length === 0) { return null; } - return { caseId, progress, @@ -117,6 +115,8 @@ const DataExportSection = () => { })); }; + if (Object.keys(sessions).length === 0) { return null; } + return (
    diff --git a/src/styles/components/_prompts.scss b/src/styles/components/_prompts.scss index 8a92472961..989ea285d3 100644 --- a/src/styles/components/_prompts.scss +++ b/src/styles/components/_prompts.scss @@ -32,6 +32,7 @@ $module-name: prompts; } &__prompt { + --prompt-background: var(--background); width: 100%; height: 100%; transform: translateX(40%); @@ -73,7 +74,7 @@ $module-name: prompts; right: 0; width: 70%; height: 1.3em; - background: linear-gradient(to right, rgba(255, 255, 255, 0) 70%, var(--background) 90%); //sass-lint:disable-line no-color-literals + background: linear-gradient(to right, rgba(255, 255, 255, 0) 70%, var(--prompt-background) 90%); //sass-lint:disable-line no-color-literals } } diff --git a/src/styles/containers/_all.scss b/src/styles/containers/_all.scss index 1b9dc121fe..17f07f04be 100644 --- a/src/styles/containers/_all.scss +++ b/src/styles/containers/_all.scss @@ -10,6 +10,6 @@ @import 'narrative-interface'; @import 'alter-form-interface'; @import 'ego-form-interface'; -@import 'dyad-interface'; +@import 'dyad-census'; @import 'interface'; @import 'StartScreen/all'; diff --git a/src/styles/containers/_dyad-interface.scss b/src/styles/containers/_dyad-census.scss similarity index 86% rename from src/styles/containers/_dyad-interface.scss rename to src/styles/containers/_dyad-census.scss index a62366da2a..8d09181264 100644 --- a/src/styles/containers/_dyad-interface.scss +++ b/src/styles/containers/_dyad-census.scss @@ -1,5 +1,6 @@ @mixin recolor-toggle($color) { .form-field-togglebutton__checkbox { + &::before { border-color: $color; } @@ -35,7 +36,7 @@ $choice-padding: 2rem; -.dyad-interface { +.dyad-census { @include interface-centering; height: 100%; padding: 1rem 0 2rem; @@ -54,7 +55,12 @@ $choice-padding: 2rem; } &__prompt { - @include interface-prompt; + .prompts__prompt { + --prompt-background: var(--light-background); + } + height: 8rem; + width: 100%; + display: flex; } &__main { @@ -103,7 +109,7 @@ $choice-padding: 2rem; border-radius: var(--border-radius); overflow: hidden; text-align: center; - margin-bottom: 1.25rem; + width: 55rem; &--invalid { outline: 0.5rem solid var(--error); @@ -150,6 +156,19 @@ $choice-padding: 2rem; justify-content: center; align-items: center; padding: unit(2) unit(4); + + .form-field-togglebutton__checkbox { + border-radius: var(--border-radius); + width: 12rem; + + &::before { + border-radius: 0.75rem; + } + + &::after { + border-radius: 0.75rem; + } + } } &__yes { @@ -162,8 +181,9 @@ $choice-padding: 2rem; } &__progress { - padding: unit(4); - background: var(--color-slate-blue); + padding: unit(1) unit(4); + width: 42rem; + margin: 1rem auto; .progress-bar { background-color: var(--transparent-light); From f94cc32f09b80c0dbe6f17db67a0e5a0309c4750 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 22 Sep 2020 16:35:23 +0100 Subject: [PATCH 44/71] fix several small bugs --- config/webpack.config.dev.js | 3 +++ config/webpack.config.prod.js | 3 +++ src/behaviours/DragAndDrop/DragSource.js | 19 +++++++++---------- .../Interfaces/DyadCensus/useAutoAdvance.js | 4 ++-- src/hooks/useInterval.js | 2 +- src/styles/components/_node-form.scss | 2 +- src/styles/containers/StartScreen/_all.scss | 2 +- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/config/webpack.config.dev.js b/config/webpack.config.dev.js index 607c9ce95d..30501ede74 100644 --- a/config/webpack.config.dev.js +++ b/config/webpack.config.dev.js @@ -28,6 +28,9 @@ const resolveAlias = { // Use the UMD version of React Markdown, so that we can keep building for // electron-renderer and running other browsers 'react-markdown': require.resolve('react-markdown/umd/react-markdown.js'), + // TODO: Track this issue + // https://github.com/mapbox/concaveman/issues/18 + tinyqueue: '../tinyqueue/tinyqueue.js', }; if (!isTargetingElectron) { resolveAlias.electron = `${paths.appSrc}/utils/electron-shim`; diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 0661b47127..2bbf854d51 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -86,6 +86,9 @@ const resolveAlias = { // Use the UMD version of React Markdown, so that we can keep building for // electron-renderer and running other browsers 'react-markdown': require.resolve('react-markdown/umd/react-markdown.js'), + // TODO: Track this issue + // https://github.com/mapbox/concaveman/issues/18 + tinyqueue: '../tinyqueue/tinyqueue.js', }; if (!isTargetingElectron) { resolveAlias.electron = `${paths.appSrc}/utils/electron-shim`; diff --git a/src/behaviours/DragAndDrop/DragSource.js b/src/behaviours/DragAndDrop/DragSource.js index e860abe138..23ac563761 100644 --- a/src/behaviours/DragAndDrop/DragSource.js +++ b/src/behaviours/DragAndDrop/DragSource.js @@ -81,16 +81,15 @@ const dragSource = WrappedComponent => }; useEffect(() => { - if (!node.current || !allowDrag) { return false; } - - dragManager = new DragManager({ - el: node.current, - onDragStart, - onDragMove, - onDragEnd, - scrollDirection, - }); - + if (node.current && allowDrag) { + dragManager = new DragManager({ + el: node.current, + onDragStart, + onDragMove, + onDragEnd, + scrollDirection, + }); + } return () => { cleanupPreview(); cleanupDragManager(); diff --git a/src/containers/Interfaces/DyadCensus/useAutoAdvance.js b/src/containers/Interfaces/DyadCensus/useAutoAdvance.js index e92c213edf..3639c61321 100644 --- a/src/containers/Interfaces/DyadCensus/useAutoAdvance.js +++ b/src/containers/Interfaces/DyadCensus/useAutoAdvance.js @@ -30,8 +30,8 @@ const useAutoAdvance = (_next, isTouched, isChanged) => { } return () => { - if (!timer.current) { return; } - clearTimeout(timer.current); + if (!timer.current) { return () => {}; } + return clearTimeout(timer.current); }; }, [isTouched]); }; diff --git a/src/hooks/useInterval.js b/src/hooks/useInterval.js index 9a0212dc9d..2ef2ef2e00 100644 --- a/src/hooks/useInterval.js +++ b/src/hooks/useInterval.js @@ -18,7 +18,7 @@ const useInterval = (callback, delay) => { return () => clearInterval(id); } - return true; + return () => {}; }, [delay]); }; diff --git a/src/styles/components/_node-form.scss b/src/styles/components/_node-form.scss index 80ea70576e..a416a31417 100644 --- a/src/styles/components/_node-form.scss +++ b/src/styles/components/_node-form.scss @@ -1,6 +1,6 @@ .overlay { &.node-form { - max-height: calc(100% - 4rem); + max-width: 70rem; &.overlay--fullscreen { max-height: 100%; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index c152fb0bf0..56dab7163c 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -362,7 +362,7 @@ &--offline, &--error { - background: #ccc; + min-height: unset; } header { From 706b9de221766e79fac5c5d956d58df3e71cf52c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 23 Sep 2020 02:23:28 +0100 Subject: [PATCH 45/71] fix start screen staggered animation --- src/components/Cards/ServerCard.js | 4 +- src/components/NewFilterableListWrapper.js | 55 +++---- src/containers/Server/DiscoveredServerList.js | 30 +--- src/containers/StartScreen/HeaderSection.js | 145 ++++++++++-------- src/containers/StartScreen/ImportSection.js | 114 +++++--------- src/containers/StartScreen/Section.js | 23 +-- .../StartScreen/ServerAddressForm.js | 4 +- src/containers/StartScreen/ServerSection.js | 28 ++-- src/containers/StartScreen/StartScreen.js | 43 +++--- .../components/_new-filterable-list.scss | 13 +- src/styles/components/_overlay.scss | 1 + src/styles/containers/StartScreen/_all.scss | 9 ++ src/styles/containers/_app.scss | 6 + src/utils/protocol/importProtocol.js | 5 +- 14 files changed, 233 insertions(+), 247 deletions(-) diff --git a/src/components/Cards/ServerCard.js b/src/components/Cards/ServerCard.js index 1a40c1bde1..5f38f328e9 100644 --- a/src/components/Cards/ServerCard.js +++ b/src/components/Cards/ServerCard.js @@ -13,9 +13,9 @@ const ServerCard = (props) => { return ( { }; const containerVariants = { - visible: { + show: { opacity: 1, transition: { + staggerChildren: 0.09, when: 'beforeChildren', - staggerChildren: 0.1, }, }, - hidden: { + hide: { opacity: 0, transition: { when: 'afterChildren', @@ -75,14 +75,14 @@ const NewFilterableListWrapper = (props) => { }; const itemVariants = { - visible: { + show: { opacity: 1, y: 0, transition: { type: 'spring', }, }, - hidden: { + hide: { opacity: 0, y: 25, transition: { @@ -134,33 +134,36 @@ const NewFilterableListWrapper = (props) => { { loading ? ( -
    +

    Loading...

    -
    +
    ) : ( - - - { - sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( - - - - )) - } - - + + { + sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( + + + + )) + } + ) } diff --git a/src/containers/Server/DiscoveredServerList.js b/src/containers/Server/DiscoveredServerList.js index cfa262000d..1bf59b2d63 100644 --- a/src/containers/Server/DiscoveredServerList.js +++ b/src/containers/Server/DiscoveredServerList.js @@ -1,30 +1,29 @@ import React, { useState, useEffect } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { AnimatePresence } from 'framer-motion'; import { Icon, Spinner, Scroller, Button } from '@codaco/ui'; import useOnlineStatus from '../../hooks/useOnlineStatus'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import ServerDiscoverer from '../../utils/serverDiscoverer'; import { ServerCard } from '../../components/Cards'; -import { ServerAddressForm } from '../StartScreen/ServerAddressForm'; /** * Displays a list of available servers discovered via MDNS. */ const DiscoveredServerList = ({ - openDialog, + onSelectServer, }) => { const onlineStatus = useOnlineStatus(); const [availableServers, updateAvailableServers] = useState([]); - const [selectedServer, setSelectedServer] = useState(null); + const [error, setError] = useState(null); - const [showServerAddressForm, setShowServerAddressForm] = useState(false); + const dispatch = useDispatch(); + const openDialog = dialog => dispatch(dialogActions.openDialog(dialog)); const handlePairingCardClick = (server) => { - setSelectedServer(server); - setShowServerAddressForm(true); + onSelectServer(server); }; useEffect(() => { @@ -138,24 +137,9 @@ const DiscoveredServerList = ({ } - setShowServerAddressForm(false)} - />
    ); }; -DiscoveredServerList.defaultProps = { -}; - -DiscoveredServerList.propTypes = { -}; - -const mapDispatchToProps = { - openDialog: dialogActions.openDialog, -}; - -export default connect(null, mapDispatchToProps)(DiscoveredServerList); +export default DiscoveredServerList; diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index 61c6f4568c..ec8ac80fc5 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; import { useSelector, useDispatch } from 'react-redux'; import { Button } from '@codaco/ui'; import { Section } from '.'; @@ -20,18 +20,32 @@ const HeaderSection = () => { const dispatch = useDispatch(); const toggleShowGettingStarted = () => dispatch(deviceSettingsActions.toggleSetting('showGettingStarted')); - getVersion().then(version => setAppVersion(version)); + useEffect(() => { + getVersion().then(version => setAppVersion(version)); + }, [onlineStatus]); const start = { - visible: { + show: { height: '100%', opacity: 1, transition: { - delay: showGettingStarted ? 0 : 1, + duration: 1, type: 'spring', }, }, - hidden: { + hide: { + height: 0, + opacity: 0, + }, + }; + + const test = { + show: { + opacity: 1, + height: '100%', + transition: { when: 'beforeChildren', delay: 0.25, type: 'spring' }, + }, + hide: { height: 0, opacity: 0, }, @@ -39,16 +53,16 @@ const HeaderSection = () => { return (
    - +
    toggleShowGettingStarted()} /> - - +
    +

    Network Canvas

    Simplifying complex network data collection.

    @@ -57,62 +71,61 @@ const HeaderSection = () => { Network Canvas
    {appVersion}
    - - - - { showGettingStarted && ( - -
    -
    -

    Getting Started

    -
    -
    -

    - If this is your first time using Network Canvas, please consider taking - a moment to watch our overview video. It will introduce you to the key - concepts of the Network Canvas project. -

    -

    - For further detailed information, tutorials, videos, and information about - collaboration, please visit our documentation website. -

    -

    - To get started right away, install one or more interview protocols - onto this device. For convenience, we have created a sample interview - protocol on the theme of "public health" research. -

    -
    -
    - - - -
    -
    -
    - )} -
    +
    + + +
    +
    +

    Getting Started

    +
    +
    +

    + If this is your first time using Network Canvas, please consider taking + a moment to watch our overview video. It will introduce you to the key + concepts of the Network Canvas project. +

    +

    + For further detailed information, tutorials, videos, and information about + collaboration, please visit our documentation website. +

    +

    + To get started right away, install one or more interview protocols + onto this device. For convenience, we have created a sample interview + protocol on the theme of "public health" research. +

    +
    +
    + + + +
    +
    +
    ); diff --git a/src/containers/StartScreen/ImportSection.js b/src/containers/StartScreen/ImportSection.js index db91752b70..49b502272a 100644 --- a/src/containers/StartScreen/ImportSection.js +++ b/src/containers/StartScreen/ImportSection.js @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { motion, AnimatePresence } from 'framer-motion'; import { GraphicButton, Button } from '@codaco/ui'; import { Section } from '.'; import { actionCreators as uiActions } from '../../ducks/modules/ui'; @@ -29,84 +28,55 @@ const ImportSection = () => { return (
    - - +
    +

    Protocols

    - - - - { - onlineStatus && ( - - -

    Import

    -

    From URL

    -
    -
    - ) - } - +
    +
    + { + onlineStatus && (

    Import

    -

    From File

    +

    From URL

    - - { - onlineStatus && pairedServerConnection === 'ok' && ( - - -

    Import

    -

    From Server

    -
    -
    - ) - } - - - + ) + } + +

    Import

    +

    From File

    +
    + { + onlineStatus && pairedServerConnection === 'ok' && ( + +

    Import

    +

    From Server

    +
    + ) + } +
    +
    { Object.keys(installedProtocols).length > 0 && ( - +
    - +
    )} { ...rest } = props; - const springy = { - visible: { + const itemVariants = { + show: { + y: 0, opacity: 1, transition: { - duration: 1, + type: 'spring', when: 'beforeChildren', - staggerChildren: 0.5, }, }, - hidden: { + hide: { + y: '10rem', opacity: 0, - transition: { - when: 'afterChildren', - }, }, }; return ( {children} @@ -37,10 +34,4 @@ const Section = (props) => { ); }; -Section.propTypes = { -}; - -Section.defaultProps = { -}; - export default Section; diff --git a/src/containers/StartScreen/ServerAddressForm.js b/src/containers/StartScreen/ServerAddressForm.js index fa2a2ba03b..57001e25b6 100644 --- a/src/containers/StartScreen/ServerAddressForm.js +++ b/src/containers/StartScreen/ServerAddressForm.js @@ -50,7 +50,7 @@ const ServerAddressForm = ({ server, }) => { const [showPairingCodeDialog, setShowPairingCodeDialog] = useState(false); - const [selectedServer, setSelectedServer] = useState(server); + const [selectedServer, setSelectedServer] = useState(null); const handleSubmit = (values) => { const serverWithPairingUrl = addPairingUrlToService({ @@ -87,7 +87,7 @@ const ServerAddressForm = ({ }; const RenderPairingForm = () => ( - +

    If you cannot use automatic Server discovery, you may still be able to pair this device with Server by manually entering connection details. diff --git a/src/containers/StartScreen/ServerSection.js b/src/containers/StartScreen/ServerSection.js index 03f3b55130..2478892d2a 100644 --- a/src/containers/StartScreen/ServerSection.js +++ b/src/containers/StartScreen/ServerSection.js @@ -1,8 +1,7 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import cx from 'classnames'; import { Button } from '@codaco/ui'; -import { actionCreators as uiActions } from '../../ducks/modules/ui'; import { actionCreators as serverActions } from '../../ducks/modules/pairedServer'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import { Section } from '.'; @@ -14,16 +13,17 @@ import { openExternalLink } from '../../components/ExternalLink'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; const ServerSection = () => { - const showServerAddressForm = useSelector(state => state.showServerAddressForm); const pairedServer = useSelector(state => state.pairedServer); + const [selectedServer, setSelectedServer] = useState(null); + const [showServerAddressForm, setShowServerAddressForm] = useState(false); + const onlineStatus = useOnlineStatus(); const pairedServerConnection = useServerConnectionStatus(pairedServer); const dispatch = useDispatch(); const openDialog = dialog => dispatch(dialogActions.openDialog(dialog)); const unpairServer = () => dispatch(serverActions.unpairServer()); - const toggleShowServerAddressForm = () => dispatch(uiActions.toggle('showServerAddressForm')); const handleUnpairRequest = () => { openDialog({ @@ -79,10 +79,6 @@ const ServerSection = () => { return (

    -
    @@ -95,7 +91,11 @@ const ServerSection = () => { To begin, open Server on a computer connected to the same network as this device. When the device appears below, click its card to start the pairing process.

    - + { + setSelectedServer(server); + setShowServerAddressForm(true); + }} + /> ) : (
    @@ -117,14 +117,20 @@ const ServerSection = () => { )}
    - + { !pairedServer ? ( - + ) : () }
    + { setShowServerAddressForm(false); setSelectedServer(null); }} + />
    ); diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 50f8d0a262..598bf2b4b8 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -1,5 +1,5 @@ import React from 'react'; -import { motion, AnimateSharedLayout } from 'framer-motion'; +import { motion } from 'framer-motion'; import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { @@ -16,15 +16,12 @@ const StartScreen = ({ activeSessionId, sessions, }) => { - const opacity = { - visible: { + const variants = { + show: { opacity: 1, - transition: { - when: 'beforeChildren', - staggerChildren: 0.15, - }, + transition: { staggerChildren: 0.3, when: 'beforeChildren' }, }, - hidden: { + hide: { opacity: 0, }, }; @@ -37,21 +34,21 @@ const StartScreen = ({ return (
    - - - - - - - - - - + + + + + + + +
    ); }; diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index eacefe9fea..889d2b4441 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -86,10 +86,15 @@ height: 100%; overflow-y: auto; padding: unit(2); - display: grid; - grid-template-columns: 1fr 1fr; - row-gap: unit(2); - column-gap: unit(2); + + // Grid would be a better way to display these items, but it + // causes distortion with framer-motion's layout prop + // display: grid; + > * { + width: calc(50% - #{unit(2)}); + float: left; + margin: unit(1); + } &:empty { display: flex; diff --git a/src/styles/components/_overlay.scss b/src/styles/components/_overlay.scss index 28b83546a0..aeaccfbea9 100644 --- a/src/styles/components/_overlay.scss +++ b/src/styles/components/_overlay.scss @@ -8,6 +8,7 @@ $close-button-size: 1rem; border-radius: .75rem; padding: 2rem; max-width: 90vw; + width: 70rem; min-width: 60rem; max-height: calc(100vh - 4rem); margin: 2rem; diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 56dab7163c..108164b1b2 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -160,6 +160,15 @@ display: flex; justify-content: space-around; + .graphic-button { + max-width: 30rem; + } + + > :only-child { + max-width: 30rem; + margin-left: -50% !important; + } + > :first-child { margin-right: unit(2); } diff --git a/src/styles/containers/_app.scss b/src/styles/containers/_app.scss index b9c8e5f7dc..6076b53e6c 100644 --- a/src/styles/containers/_app.scss +++ b/src/styles/containers/_app.scss @@ -3,6 +3,12 @@ width: 100%; } +// NC specific override for stacking context +.toast-container { + position: absolute; + z-index: var(--z-fx); +} + // container for menus and content .app { diff --git a/src/utils/protocol/importProtocol.js b/src/utils/protocol/importProtocol.js index b5d85bebcc..0240f04c22 100644 --- a/src/utils/protocol/importProtocol.js +++ b/src/utils/protocol/importProtocol.js @@ -140,7 +140,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { dispatch(toastActions.addToast({ type: 'success', title: 'Finished!', - autoDismiss: true, + autoDismiss: false, content: (

    Protocol installed successfully.

    @@ -261,10 +261,11 @@ export const importProtocolFromFile = (filePath, name) => { // Remove the status toast dispatch(toastActions.removeToast(toastUUID)); + console.log('here'); dispatch(toastActions.addToast({ type: 'success', title: 'Finished!', - autoDismiss: true, + autoDismiss: false, content: (

    Protocol installed successfully.

    From d29c04cc1295671ea002255cea3615f8d193557c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 23 Sep 2020 11:18:09 +0100 Subject: [PATCH 46/71] fix menu animation issues --- src/components/NewFilterableListWrapper.js | 47 +++---- src/components/Scroller.js | 1 + .../SessionPanel/SessionNavigation.js | 123 ++++++++++-------- src/components/SessionPanel/SessionPanel.js | 22 ++-- src/components/SettingsMenu/SettingsMenu.js | 5 +- src/components/StagesMenu/StagesMenu.js | 22 ++-- src/containers/StartScreen/HeaderSection.js | 3 +- .../StartScreen/InterviewSection.js | 6 +- src/containers/StartScreen/StartScreen.js | 2 +- .../components/_new-filterable-list.scss | 1 - src/styles/components/_node-form.scss | 41 +++--- src/styles/components/_overlay.scss | 14 +- src/styles/containers/StartScreen/_all.scss | 10 +- 13 files changed, 162 insertions(+), 135 deletions(-) diff --git a/src/components/NewFilterableListWrapper.js b/src/components/NewFilterableListWrapper.js index 317d4c8df5..92c5156d7d 100644 --- a/src/components/NewFilterableListWrapper.js +++ b/src/components/NewFilterableListWrapper.js @@ -7,6 +7,7 @@ import { Button, Spinner } from '@codaco/ui'; import { Text } from '@codaco/ui/lib/components/Fields'; import { entityAttributesProperty } from '../ducks/modules/network'; import sortOrder from '../utils/sortOrder'; +import { Scroller } from '.'; const NewFilterableListWrapper = (props) => { const { @@ -143,28 +144,30 @@ const NewFilterableListWrapper = (props) => {

    Loading...

    ) : ( - - - { - sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( - - - - )) - } - - + + + + { + sortedAndFilteredList.length > 0 && sortedAndFilteredList.map(item => ( + + + + )) + } + + + ) }
    diff --git a/src/components/Scroller.js b/src/components/Scroller.js index 89baa3fd4f..45ac784341 100644 --- a/src/components/Scroller.js +++ b/src/components/Scroller.js @@ -34,6 +34,7 @@ class Scroller extends Component { showScrollbars={showScrollbars} onScroll={this.handleScroll} useSmoothScrolling={useSmoothScrolling} + forwardedRef={this.scrollable} > {children} diff --git a/src/components/SessionPanel/SessionNavigation.js b/src/components/SessionPanel/SessionNavigation.js index 409f4f4b1c..03d29e46b7 100644 --- a/src/components/SessionPanel/SessionNavigation.js +++ b/src/components/SessionPanel/SessionNavigation.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Icon, ProgressBar } from '@codaco/ui'; -import { motion } from 'framer-motion'; +import { AnimateSharedLayout, motion } from 'framer-motion'; const SessionNavigation = (props) => { const { @@ -15,6 +15,9 @@ const SessionNavigation = (props) => { const variants = { normal: { opacity: 1, + transition: { + delay: 0.5, + }, }, expanded: { opacity: 0, @@ -22,65 +25,71 @@ const SessionNavigation = (props) => { }; return ( - + { - setShowSubMenu(true); - setExpanded(true); - }} + key="session-navigation" + variants={variants} + initial="expanded" + exit="expanded" + animate="normal" + className="session-navigation" + layout > - + { + setShowSubMenu(true); + setExpanded(true); + }} + layout + > + + + { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + onClickBack(e); + }} + layout + > + + + { setShowSubMenu(false); setExpanded(true); }} + layout + > + + + { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + onClickNext(e); + }} + layout + > + + - { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - onClickBack(e); - }} - > - - - { setShowSubMenu(false); setExpanded(true); }} - > - - - { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - onClickNext(e); - }} - > - - - + ); }; diff --git a/src/components/SessionPanel/SessionPanel.js b/src/components/SessionPanel/SessionPanel.js index 2e41d96ac1..c704fe53c1 100644 --- a/src/components/SessionPanel/SessionPanel.js +++ b/src/components/SessionPanel/SessionPanel.js @@ -60,16 +60,18 @@ const SessionPanel = React.forwardRef((props, ref) => { easing: baseAnimationEasing, }} > - { expanded ? menuContent : ( - - ) } + + { expanded ? menuContent : ( + + ) } + ); diff --git a/src/components/SettingsMenu/SettingsMenu.js b/src/components/SettingsMenu/SettingsMenu.js index 47a2ee92c7..6ca9b3eeba 100644 --- a/src/components/SettingsMenu/SettingsMenu.js +++ b/src/components/SettingsMenu/SettingsMenu.js @@ -170,6 +170,7 @@ const SettingsMenu = (props) => { { settingsMenuOpen && ( { >

    Settings

      { renderNavigation }
    - + {/* The presence animation is temporarily disabled because it breaks the tests (possible bug) */} diff --git a/src/components/StagesMenu/StagesMenu.js b/src/components/StagesMenu/StagesMenu.js index 402076d621..54fa2c97c8 100644 --- a/src/components/StagesMenu/StagesMenu.js +++ b/src/components/StagesMenu/StagesMenu.js @@ -2,7 +2,7 @@ import React, { useState, useRef, useLayoutEffect } from 'react'; import PropTypes from 'prop-types'; import { Text } from '@codaco/ui/lib/components/Fields'; import { connect } from 'react-redux'; -import { motion, AnimatePresence } from 'framer-motion'; +import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; import { compose } from 'recompose'; import { getCSSVariableAsNumber, getCSSVariableAsString } from '@codaco/ui/lib/utils/CSSVariables'; import { getProtocolStages } from '../../selectors/protocol'; @@ -25,7 +25,7 @@ const StagesMenu = (props) => { const baseAnimationDuration = getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; const baseAnimationEasing = getCSSVariableAsString('--animation-easing-json'); - const variants = { + const containerVariants = { normal: { opacity: 0, transition: { @@ -82,12 +82,12 @@ const StagesMenu = (props) => { return 0; }; - const timelineVariants = { + const itemVariants = { expanded: currentItemIndex => ({ x: 0, opacity: 1, transition: { - delay: calculateDelay(currentItemIndex) + 0.25, + delay: calculateDelay(currentItemIndex) + 0.5, duration: baseAnimationDuration, easing: baseAnimationEasing, }, @@ -127,11 +127,12 @@ const StagesMenu = (props) => { return ( { if (!filter) { const itemHeight = document.getElementsByClassName('stages-menu__preview-wrapper')[0].clientHeight; + console.log('calling scrtoll', currentStageIndex, itemHeight); scrollToLocation(currentStageIndex * itemHeight, 0.2); } }, [imageLoaded]); @@ -173,7 +175,7 @@ const StagesMenu = (props) => { {
    {renderMenuItems.length > 0 ? ( - - { renderMenuItems } - + + + { renderMenuItems } + + ) : (

    No stages match your filter.

    diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index ec8ac80fc5..20f8481349 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -29,7 +29,6 @@ const HeaderSection = () => { height: '100%', opacity: 1, transition: { - duration: 1, type: 'spring', }, }, @@ -43,7 +42,7 @@ const HeaderSection = () => { show: { opacity: 1, height: '100%', - transition: { when: 'beforeChildren', delay: 0.25, type: 'spring' }, + transition: { when: 'beforeChildren', type: 'spring' }, }, hide: { height: 0, diff --git a/src/containers/StartScreen/InterviewSection.js b/src/containers/StartScreen/InterviewSection.js index 4037c5d12d..e7f51c4960 100644 --- a/src/containers/StartScreen/InterviewSection.js +++ b/src/containers/StartScreen/InterviewSection.js @@ -77,7 +77,7 @@ const InterviewSection = () => { Object.keys(installedProtocols).length > 1 && (
    toggleUIOverlay('showStartInterviewPicker')} @@ -111,7 +111,7 @@ const InterviewSection = () => {
    -

    Resume an Interview

    +

    Resume Last Interview

    { { Object.keys(sessions).length > 1 && (
    toggleUIOverlay('showResumeSessionPicker')} diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 598bf2b4b8..2d43ee8688 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -19,7 +19,7 @@ const StartScreen = ({ const variants = { show: { opacity: 1, - transition: { staggerChildren: 0.3, when: 'beforeChildren' }, + transition: { staggerChildren: 0.32, when: 'beforeChildren' }, }, hide: { opacity: 0, diff --git a/src/styles/components/_new-filterable-list.scss b/src/styles/components/_new-filterable-list.scss index 889d2b4441..b3ab1aaf91 100644 --- a/src/styles/components/_new-filterable-list.scss +++ b/src/styles/components/_new-filterable-list.scss @@ -84,7 +84,6 @@ .filterable-list-scroller { height: 100%; - overflow-y: auto; padding: unit(2); // Grid would be a better way to display these items, but it diff --git a/src/styles/components/_node-form.scss b/src/styles/components/_node-form.scss index a416a31417..36f4b72041 100644 --- a/src/styles/components/_node-form.scss +++ b/src/styles/components/_node-form.scss @@ -1,24 +1,27 @@ .overlay { - &.node-form { - max-width: 70rem; - &.overlay--fullscreen { - max-height: 100%; - } - - .overlay__content { - display: flex; - flex-direction: column; - } - - .scrollable { - height: calc(100% - 4rem); - padding-top: 1.5rem; - } - - .node-form__footer { - text-align: right; - height: 4rem; + &:not(.overlay--fullscreen) { + &.node-form { + max-width: 70rem; + + &.overlay--fullscreen { + max-height: 100%; + } + + .overlay__content { + display: flex; + flex-direction: column; + } + + .scrollable { + height: calc(100% - 4rem); + padding-top: 1.5rem; + } + + .node-form__footer { + text-align: right; + height: 4rem; + } } } } diff --git a/src/styles/components/_overlay.scss b/src/styles/components/_overlay.scss index aeaccfbea9..e34196f29a 100644 --- a/src/styles/components/_overlay.scss +++ b/src/styles/components/_overlay.scss @@ -7,10 +7,9 @@ $close-button-size: 1rem; position: relative; border-radius: .75rem; padding: 2rem; - max-width: 90vw; width: 70rem; - min-width: 60rem; max-height: calc(100vh - 4rem); + max-width: calc(100vw - 2rem); margin: 2rem; display: flex; flex-direction: column; @@ -48,13 +47,18 @@ $close-button-size: 1rem; &--fullscreen { display: block; - height: 100%; - width: 100%; - max-height: 100%; + width: 100vw; + max-width: none; + height: 100vh; + max-height: unset; border-radius: 0; padding: 6rem 10rem; margin: 0; + .overlay__content { + height: 100%; + } + .input__container--hidden { position: absolute; } diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index 108164b1b2..e2e8fea950 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -554,16 +554,16 @@ .stack-of-cards { - width: 16rem; - height: 11rem; + width: 14rem; + height: 9rem; display: flex; align-items: flex-end; position: relative; .mock-card { position: absolute; - width: 15rem; - height: 10rem; + width: 13rem; + height: 8rem; border-radius: var(--border-radius); background: var(--color-slate-blue--dark); box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.3); @@ -572,7 +572,7 @@ .main { width: 100%; - height: 7rem; + height: 5rem; } &--two { From 708c9fce029b85ea964ea6cf6cc3ca4dae9e03a1 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 12:31:27 +0100 Subject: [PATCH 47/71] last attempt at selectable --- src/behaviours/DragAndDrop/DragPreview.js | 2 - src/behaviours/DragAndDrop/DragSource.js | 19 +-- src/behaviours/index.js | 1 - src/behaviours/selectable.js | 75 +++++------ src/components/Card.js | 5 +- src/components/CardList.js | 33 +++-- .../SessionPanel/SessionNavigation.js | 123 +++++++++--------- src/components/SessionPanel/SessionPanel.js | 8 -- src/components/StagesMenu/StagesMenu.js | 1 - src/containers/Canvas/LayoutNode.js | 5 +- src/containers/Node.js | 4 +- src/ducks/modules/__tests__/errors.test.js | 6 +- src/styles/components/_draggable-preview.scss | 10 +- src/styles/main.scss | 1 - src/styles/utils/_all.scss | 2 - src/styles/utils/_draggable-preview.scss | 5 - src/styles/utils/_draggable.scss | 3 - .../__tests__/importProtocol.test.js | 10 +- src/utils/protocol/importProtocol.js | 4 +- 19 files changed, 145 insertions(+), 172 deletions(-) delete mode 100644 src/styles/utils/_all.scss delete mode 100644 src/styles/utils/_draggable-preview.scss delete mode 100644 src/styles/utils/_draggable.scss rename src/{ducks/modules => utils/protocol}/__tests__/importProtocol.test.js (65%) diff --git a/src/behaviours/DragAndDrop/DragPreview.js b/src/behaviours/DragAndDrop/DragPreview.js index ebb40946a0..e54ac2758e 100644 --- a/src/behaviours/DragAndDrop/DragPreview.js +++ b/src/behaviours/DragAndDrop/DragPreview.js @@ -21,8 +21,6 @@ export default class DraggablePreview { this.node = document.createElement('div'); this.initialSize = getSize(node); this.node.setAttribute('class', 'draggable-preview'); - // this.x = -1000; - // this.y = -1000; this.validMove = true; this.update(); diff --git a/src/behaviours/DragAndDrop/DragSource.js b/src/behaviours/DragAndDrop/DragSource.js index 23ac563761..e860abe138 100644 --- a/src/behaviours/DragAndDrop/DragSource.js +++ b/src/behaviours/DragAndDrop/DragSource.js @@ -81,15 +81,16 @@ const dragSource = WrappedComponent => }; useEffect(() => { - if (node.current && allowDrag) { - dragManager = new DragManager({ - el: node.current, - onDragStart, - onDragMove, - onDragEnd, - scrollDirection, - }); - } + if (!node.current || !allowDrag) { return false; } + + dragManager = new DragManager({ + el: node.current, + onDragStart, + onDragMove, + onDragEnd, + scrollDirection, + }); + return () => { cleanupPreview(); cleanupDragManager(); diff --git a/src/behaviours/index.js b/src/behaviours/index.js index 6ca993d3f4..2d0ddfec5f 100644 --- a/src/behaviours/index.js +++ b/src/behaviours/index.js @@ -1,5 +1,4 @@ /* eslint-disable @codaco/spellcheck/spell-checker */ -export { default as selectable } from './selectable'; export { default as scrollable } from './scrollable'; export { default as autoInitialisedForm } from './autoInitialisedForm'; export { default as withBounds } from './withBounds'; diff --git a/src/behaviours/selectable.js b/src/behaviours/selectable.js index cc27be8f25..9aad76a7bf 100644 --- a/src/behaviours/selectable.js +++ b/src/behaviours/selectable.js @@ -1,42 +1,39 @@ -/* eslint-disable react/no-find-dom-node */ - -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; - -export const selectable = WrappedComponent => ({ - onSelected, - allowSelect = true, - ...rest -}) => { - const [selected, setSelected] = useState(false); - - const handleSelect = () => { - if (!allowSelect) { return; } - - if (!selected) { - setSelected(true); - onSelected(); - return; - } - - setSelected(false); +import React, { useRef, useEffect } from 'react'; +import { findDOMNode } from 'react-dom'; + +const selectable = WrappedComponent => + (props) => { + const el = useRef(); + const onTap = () => { + console.log('onTap'); + props.onSelected(); + }; + + let element; + + useEffect(() => { + console.log('selectable effect', el); + + if (el.current) { + element = findDOMNode(el.current); + element.addEventListener('click', onTap, { passive: true }); + } + + return () => { + if (element) { + element.removeEventListener('click', onTap); + } + }; + }, [el]); + + const { + allowSelect, + onSelected, + ...rest + } = props; + console.log('selectable', props); + // if (!allowSelect) { return ; } + return ; }; - return ( -
    - -
    - ); -}; - -selectable.propTypes = { - onSelected: PropTypes.func, - allowSelect: PropTypes.bool, -}; - -selectable.defaultProps = { - onSelected: null, - allowSelect: true, -}; - export default selectable; diff --git a/src/components/Card.js b/src/components/Card.js index 9a21ce2f11..f7f6db6cae 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -11,6 +11,7 @@ class Card extends PureComponent { details, label, selected, + onSelected, } = this.props; const attributes = details.map( @@ -30,7 +31,7 @@ class Card extends PureComponent { }); return ( -
    +
    { - // Tuple in format of [ratio, noOfColumns] - const ratios = [ - [16 / 9, 4], - [16 / 10, 3], - [4 / 3, 2], - ]; - - const windowRatio = width / height; - - // Calculate appropriate col number by finding closest ratio, or defaulting to 1 - const [, columns] = ratios.find(([ratio]) => windowRatio > ratio) || [0, 1]; - - return columns; -}; +/* eslint-disable */ +// [ ratio, columns ] +// First match (windowRatio > ratio) sets columns +const ratios = [ + [ 16/ 9, 4 ], + [ 16/10, 3 ], + [ 4/ 3, 2 ], +]; +/* eslint-enable */ const EnhancedCard = selectable(Card); @@ -44,7 +37,11 @@ class CardList extends Component { } componentWillMount() { - this.columns = calculateRequiredColumns(window.innerWidth, window.innerHeight); + const windowRatio = window.innerWidth / window.innerHeight; + + const [, columns] = ratios.find(([ratio]) => windowRatio > ratio); + + this.columns = columns; } getColumns = () => diff --git a/src/components/SessionPanel/SessionNavigation.js b/src/components/SessionPanel/SessionNavigation.js index 03d29e46b7..c90470397a 100644 --- a/src/components/SessionPanel/SessionNavigation.js +++ b/src/components/SessionPanel/SessionNavigation.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Icon, ProgressBar } from '@codaco/ui'; -import { AnimateSharedLayout, motion } from 'framer-motion'; +import { motion } from 'framer-motion'; const SessionNavigation = (props) => { const { @@ -17,79 +17,76 @@ const SessionNavigation = (props) => { opacity: 1, transition: { delay: 0.5, + duration: 0.25, }, }, expanded: { opacity: 0, + transition: { + duration: 0.05, + }, }, }; return ( - + + { + setShowSubMenu(true); + setExpanded(true); + }} + > + + + { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + onClickBack(e); + }} + > + + + { setShowSubMenu(false); setExpanded(true); }} + > + + { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + onClickNext(e); + }} > - { - setShowSubMenu(true); - setExpanded(true); - }} - layout - > - - - { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - onClickBack(e); - }} - layout - > - - - { setShowSubMenu(false); setExpanded(true); }} - layout - > - - - { - if (e) { - e.stopPropagation(); - e.preventDefault(); - } - onClickNext(e); - }} - layout - > - - + - + ); }; diff --git a/src/components/SessionPanel/SessionPanel.js b/src/components/SessionPanel/SessionPanel.js index c704fe53c1..0468093a14 100644 --- a/src/components/SessionPanel/SessionPanel.js +++ b/src/components/SessionPanel/SessionPanel.js @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { compose } from 'recompose'; import { motion, AnimatePresence, AnimateSharedLayout } from 'framer-motion'; -import { getCSSVariableAsNumber, getCSSVariableAsString } from '@codaco/ui/lib/utils/CSSVariables'; import { DropObstacle } from '../../behaviours/DragAndDrop'; import StagesMenu from '../StagesMenu/StagesMenu'; import SubMenu from './SubMenu'; @@ -20,9 +19,6 @@ const SessionPanel = React.forwardRef((props, ref) => { const [expanded, setExpanded] = useState(false); const [showSubMenu, setShowSubMenu] = useState(false); - const baseAnimationDuration = getCSSVariableAsNumber('--animation-duration-standard-ms') / 1000; - const baseAnimationEasing = getCSSVariableAsString('--animation-easing-json'); - const resetMenuState = () => { setExpanded(false); setShowSubMenu(false); @@ -55,10 +51,6 @@ const SessionPanel = React.forwardRef((props, ref) => { className="session-panel" key="session-panel" layout - transition={{ - duration: baseAnimationDuration, - easing: baseAnimationEasing, - }} > { expanded ? menuContent : ( diff --git a/src/components/StagesMenu/StagesMenu.js b/src/components/StagesMenu/StagesMenu.js index 54fa2c97c8..e03ec239e6 100644 --- a/src/components/StagesMenu/StagesMenu.js +++ b/src/components/StagesMenu/StagesMenu.js @@ -166,7 +166,6 @@ const StagesMenu = (props) => { if (!filter) { const itemHeight = document.getElementsByClassName('stages-menu__preview-wrapper')[0].clientHeight; - console.log('calling scrtoll', currentStageIndex, itemHeight); scrollToLocation(currentStageIndex * itemHeight, 0.2); } }, [imageLoaded]); diff --git a/src/containers/Canvas/LayoutNode.js b/src/containers/Canvas/LayoutNode.js index 12ccb54c8b..76c7335953 100644 --- a/src/containers/Canvas/LayoutNode.js +++ b/src/containers/Canvas/LayoutNode.js @@ -1,15 +1,14 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { compose } from 'recompose'; - import Node from '../Node'; -import { selectable } from '../../behaviours'; +import selectable from '../../behaviours/selectable'; import { DragSource } from '../../behaviours/DragAndDrop'; import { NO_SCROLL } from '../../behaviours/DragAndDrop/DragManager'; import { getEntityAttributes } from '../../ducks/modules/network'; const EnhancedNode = compose( - DragSource, + // DragSource, selectable, )(Node); diff --git a/src/containers/Node.js b/src/containers/Node.js index 05823a2415..97d7624866 100644 --- a/src/containers/Node.js +++ b/src/containers/Node.js @@ -105,6 +105,8 @@ Node.defaultProps = { workerNetwork: null, }; -export default connect(mapStateToProps)(Node); +// export default connect(mapStateToProps)(Node); + +export default Node; export { Node }; diff --git a/src/ducks/modules/__tests__/errors.test.js b/src/ducks/modules/__tests__/errors.test.js index 7f3240bf9b..5302888c3e 100644 --- a/src/ducks/modules/__tests__/errors.test.js +++ b/src/ducks/modules/__tests__/errors.test.js @@ -3,7 +3,7 @@ import { createEpicMiddleware } from 'redux-observable'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import { actionTypes as dialogActionTypes } from '../dialogs'; -import { actionCreators as importProtocolActions, actionTypes as importProtocolActionTypes } from '../importProtocol'; +import { actionCreators as installedProtocolActions, actionTypes as installedProtocolActionTypes } from '../installedProtocols'; import { actionCreators as serverActions, actionTypes as serverActionTypes } from '../pairedServer'; import { epics as errorsEpic } from '../errors'; @@ -36,7 +36,7 @@ describe('errors', () => { expect(actionListener).lastCalledWith( expect.objectContaining({ error: mockError, - type: importProtocolActionTypes.IMPORT_PROTOCOL_FAILED, + type: installedProtocolActionTypes.IMPORT_PROTOCOL_FAILED, }), ); }) @@ -54,7 +54,7 @@ describe('errors', () => { }); const store = getStore(actionListener); - store.dispatch(importProtocolActions.importProtocolFailed(mockError)); + store.dispatch(installedProtocolActions.importProtocolFailed(mockError)); }); it('pairingFailed', (done) => { diff --git a/src/styles/components/_draggable-preview.scss b/src/styles/components/_draggable-preview.scss index 2dda85b06e..9aefadd557 100644 --- a/src/styles/components/_draggable-preview.scss +++ b/src/styles/components/_draggable-preview.scss @@ -1,5 +1,13 @@ .draggable-preview { z-index: var(--z-fx-sub); opacity: 0.5; - cursor: grabbing; + + .node { + transform: translate(-25%, -25%); + position: absolute; + } } + +.draggable { + cursor: grab; +} \ No newline at end of file diff --git a/src/styles/main.scss b/src/styles/main.scss index 1083f62659..682cbe22ec 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -2,7 +2,6 @@ $font-path: '~@codaco/ui/lib/assets/fonts'; $image-path: '~@codaco/ui/lib/assets/images'; @import '~@codaco/ui/lib/styles/all'; -@import 'utils/all'; @import 'helpers/all'; @import 'components/all'; @import 'containers/all'; diff --git a/src/styles/utils/_all.scss b/src/styles/utils/_all.scss deleted file mode 100644 index 973372a131..0000000000 --- a/src/styles/utils/_all.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import './draggable'; -@import './draggable-preview'; diff --git a/src/styles/utils/_draggable-preview.scss b/src/styles/utils/_draggable-preview.scss deleted file mode 100644 index df43fdf554..0000000000 --- a/src/styles/utils/_draggable-preview.scss +++ /dev/null @@ -1,5 +0,0 @@ -.draggable-preview { - &__animation { - @include animation(zoom); - } -} diff --git a/src/styles/utils/_draggable.scss b/src/styles/utils/_draggable.scss deleted file mode 100644 index f1f94aedd5..0000000000 --- a/src/styles/utils/_draggable.scss +++ /dev/null @@ -1,3 +0,0 @@ -.draggable { - cursor: grab; -} diff --git a/src/ducks/modules/__tests__/importProtocol.test.js b/src/utils/protocol/__tests__/importProtocol.test.js similarity index 65% rename from src/ducks/modules/__tests__/importProtocol.test.js rename to src/utils/protocol/__tests__/importProtocol.test.js index 46d2e8ed7d..de0ec2a7f6 100644 --- a/src/ducks/modules/__tests__/importProtocol.test.js +++ b/src/utils/protocol/__tests__/importProtocol.test.js @@ -1,7 +1,7 @@ /* eslint-env jest */ /* eslint-disable @codaco/spellcheck/spell-checker */ -import reducer, { initialState, helpers } from '../importProtocol'; +import { helpers } from '../importProtocol'; describe('importProtocol', () => { describe('helpers', () => { @@ -10,12 +10,4 @@ describe('importProtocol', () => { expect(helpers.filenameFromURI(exampleUrl)).toEqual('Public Health Protocol.netcanvas'); }); }); - - describe('reducer', () => { - it('should return the initial state', () => { - expect( - reducer(undefined, {}), - ).toEqual(initialState); - }); - }); }); diff --git a/src/utils/protocol/importProtocol.js b/src/utils/protocol/importProtocol.js index 0240f04c22..6a9487f1c2 100644 --- a/src/utils/protocol/importProtocol.js +++ b/src/utils/protocol/importProtocol.js @@ -140,7 +140,7 @@ export const importProtocolFromURI = (uri, usePairedServer) => { dispatch(toastActions.addToast({ type: 'success', title: 'Finished!', - autoDismiss: false, + autoDismiss: true, content: (

    Protocol installed successfully.

    @@ -265,7 +265,7 @@ export const importProtocolFromFile = (filePath, name) => { dispatch(toastActions.addToast({ type: 'success', title: 'Finished!', - autoDismiss: false, + autoDismiss: true, content: (

    Protocol installed successfully.

    From ed3f27735c065ada193b3a98a6cec0a76ae6a70e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 14:31:21 +0100 Subject: [PATCH 48/71] remove selectable again --- public/components/mainMenu.js | 2 +- src/behaviours/selectable.js | 39 ------------------------- src/components/Canvas/NodeLayout.js | 18 +----------- src/components/CardList.js | 6 ++-- src/components/FilterableListWrapper.js | 1 - src/containers/Canvas/LayoutNode.js | 6 ++-- src/containers/Node.js | 4 +-- 7 files changed, 8 insertions(+), 68 deletions(-) delete mode 100644 src/behaviours/selectable.js diff --git a/public/components/mainMenu.js b/public/components/mainMenu.js index 4ff928c880..32498f350a 100644 --- a/public/components/mainMenu.js +++ b/public/components/mainMenu.js @@ -100,7 +100,7 @@ const MenuTemplate = (window) => { if (process.platform !== 'darwin') { // Use File> menu for Windows - menu[0].submenu.concat(appMenu); + menu[0].submenu = menu[0].submenu.concat(appMenu); } else { // Use "App" menu for OS X menu.unshift({ diff --git a/src/behaviours/selectable.js b/src/behaviours/selectable.js deleted file mode 100644 index 9aad76a7bf..0000000000 --- a/src/behaviours/selectable.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useRef, useEffect } from 'react'; -import { findDOMNode } from 'react-dom'; - -const selectable = WrappedComponent => - (props) => { - const el = useRef(); - const onTap = () => { - console.log('onTap'); - props.onSelected(); - }; - - let element; - - useEffect(() => { - console.log('selectable effect', el); - - if (el.current) { - element = findDOMNode(el.current); - element.addEventListener('click', onTap, { passive: true }); - } - - return () => { - if (element) { - element.removeEventListener('click', onTap); - } - }; - }, [el]); - - const { - allowSelect, - onSelected, - ...rest - } = props; - console.log('selectable', props); - // if (!allowSelect) { return ; } - return ; - }; - -export default selectable; diff --git a/src/components/Canvas/NodeLayout.js b/src/components/Canvas/NodeLayout.js index 43feeb2583..39b8bfafb6 100644 --- a/src/components/Canvas/NodeLayout.js +++ b/src/components/Canvas/NodeLayout.js @@ -1,17 +1,9 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { isEmpty, isEqual, pick, has, isNil } from 'lodash'; +import { isEmpty, has, isNil } from 'lodash'; import LayoutNode from '../../containers/Canvas/LayoutNode'; import { entityPrimaryKeyProperty, getEntityAttributes, entityAttributesProperty } from '../../ducks/modules/network'; -const watchProps = ['width', 'height', 'rerenderCount']; - -const propsChangedExcludingNodes = (nextProps, props) => - !isEqual(pick(nextProps, watchProps), pick(props, watchProps)); - -const nodesLengthChanged = (nextProps, props) => - nextProps.nodes.length !== props.nodes.length; - class NodeLayout extends Component { static propTypes = { nodes: PropTypes.array, @@ -36,14 +28,6 @@ class NodeLayout extends Component { height: null, }; - shouldComponentUpdate(nextProps) { - if (nodesLengthChanged(nextProps, this.props)) { return true; } - if (!isEqual(nextProps.highlightAttribute, this.props.highlightAttribute)) { return true; } - if (propsChangedExcludingNodes(nextProps, this.props)) { return true; } - - return false; - } - isHighlighted(node) { return !isEmpty(this.props.highlightAttribute) && node[entityAttributesProperty][this.props.highlightAttribute] === true; diff --git a/src/components/CardList.js b/src/components/CardList.js index 1ca4bbce0c..4b53acd589 100644 --- a/src/components/CardList.js +++ b/src/components/CardList.js @@ -4,7 +4,6 @@ import cx from 'classnames'; import { connect } from 'react-redux'; import { times } from 'lodash'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; -import selectable from '../behaviours/selectable'; import { Card } from '.'; import { entityPrimaryKeyProperty } from '../ducks/modules/network'; @@ -18,8 +17,6 @@ const ratios = [ ]; /* eslint-enable */ -const EnhancedCard = selectable(Card); - /** * Card List */ @@ -106,7 +103,7 @@ class CardList extends Component { className="card-list__content" key={getKey(node)} > - diff --git a/src/components/FilterableListWrapper.js b/src/components/FilterableListWrapper.js index ffea20f04d..6ff4f582ee 100644 --- a/src/components/FilterableListWrapper.js +++ b/src/components/FilterableListWrapper.js @@ -100,7 +100,6 @@ class FilterableListWrapper extends Component { const sorter = sortOrder([this.state.activeSortOrder]); const sortedNodes = this.getFilteredList(sorter(items)); - return (
    diff --git a/src/containers/Canvas/LayoutNode.js b/src/containers/Canvas/LayoutNode.js index 76c7335953..6cc8f13f4e 100644 --- a/src/containers/Canvas/LayoutNode.js +++ b/src/containers/Canvas/LayoutNode.js @@ -2,14 +2,12 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { compose } from 'recompose'; import Node from '../Node'; -import selectable from '../../behaviours/selectable'; import { DragSource } from '../../behaviours/DragAndDrop'; import { NO_SCROLL } from '../../behaviours/DragAndDrop/DragManager'; import { getEntityAttributes } from '../../ducks/modules/network'; const EnhancedNode = compose( - // DragSource, - selectable, + DragSource, )(Node); class LayoutNode extends PureComponent { @@ -36,9 +34,9 @@ class LayoutNode extends PureComponent {
    Date: Tue, 29 Sep 2020 19:34:31 +0100 Subject: [PATCH 49/71] fix session back navigation animation --- package-lock.json | 18 ++-- package.json | 2 +- src/containers/Interfaces/FinishSession.js | 95 ++++++---------------- src/containers/ProtocolScreen.js | 2 +- src/ducks/modules/navigate.js | 8 +- src/styles/containers/_dyad-census.scss | 12 ++- 6 files changed, 44 insertions(+), 93 deletions(-) diff --git a/package-lock.json b/package-lock.json index acef9bc33c..eb3aba5438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14120,15 +14120,15 @@ } }, "framer-motion": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.6.0.tgz", - "integrity": "sha512-burXD+3V+bE0PsIopS7+EKiqtBv0bxCi0mK/y1mXCMlN8LqHnaiNFbgaVGPEYb8gcPtSeHW9Cu/8yb8kNxukiQ==", + "version": "2.7.6", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-2.7.6.tgz", + "integrity": "sha512-FhoU46MHqD0deJ5GRr9I8wKGTVftVtW+upT1uiqhmxWE0zzmcv4sAgdYAUTpMG9nZJf4FeFuItNxLElmcA/Clw==", "dev": true, "requires": { "@emotion/is-prop-valid": "^0.8.2", - "framesync": "^4.0.4", + "framesync": "^4.1.0", "hey-listen": "^1.0.8", - "popmotion": "9.0.0-rc.7", + "popmotion": "9.0.0-rc.19", "style-value-types": "^3.1.9", "tslib": "^1.10.0" } @@ -21205,12 +21205,12 @@ "dev": true }, "popmotion": { - "version": "9.0.0-rc.7", - "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.0.0-rc.7.tgz", - "integrity": "sha512-sjPhOJtrQAQsSKsn5KKi0Q1E2pjnLozZ08m0zWX1OqPX3ERPH0txG2zwwHMnDoAHGHAkVujlAdFZ4ERLbKGMDA==", + "version": "9.0.0-rc.19", + "resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.0.0-rc.19.tgz", + "integrity": "sha512-rmXYVzkFPHZAqTgnlrif2Wiojv2qOra0IFb22Md/Uogqi7ZLPi7EoVbZzwQxoSpbeixWx8+yr4LamAXqsFj4OQ==", "dev": true, "requires": { - "framesync": "^4.0.4", + "framesync": "^4.1.0", "hey-listen": "^1.0.8", "style-value-types": "^3.1.9", "tslib": "^1.10.0" diff --git a/package.json b/package.json index c682fb73a2..74c3069bff 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "eventemitter3": "^3.1.0", "faker": "^4.1.0", "file-loader": "1.1.11", - "framer-motion": "^2.6.0", + "framer-motion": "^2.7.6", "fs-extra": "3.0.1", "fuse.js": "^3.4.5", "history": "^4.7.2", diff --git a/src/containers/Interfaces/FinishSession.js b/src/containers/Interfaces/FinishSession.js index 2a77db12c7..539e948a55 100644 --- a/src/containers/Interfaces/FinishSession.js +++ b/src/containers/Interfaces/FinishSession.js @@ -1,93 +1,46 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Button } from '@codaco/ui'; -import { Toggle } from '@codaco/ui/lib/components/Fields'; - import { actionCreators as sessionActions } from '../../ducks/modules/session'; -import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; -import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import Scroller from '../../components/Scroller'; -class FinishSession extends Component { - constructor() { - super(); - this.state = { - showExportSessionOverlay: false, - deleteAfterFinish: false, - }; - } +const FinishSession = ({ endSession }) => { + const handleFinishSession = () => { + endSession(false, true); + }; - handleFinishSession = () => { - if (this.state.deleteAfterFinish) { - this.props.openDialog({ - type: 'Warning', - title: 'Finish and delete?', - confirmLabel: 'Finish and delete', - onConfirm: () => this.props.endSession(this.state.deleteAfterFinish, true), - message: ( - + return ( +
    +
    +

    + Finish Interview +

    + +

    - You have the "Delete this session after finishing" option enabled. - Continuing will delete this interview session, regardless of if you have uploaded or - exported it. + You have reached the end of the interview. + If you are satisfied with the information you have entered, you may finish the + interview now.

    -

    Are you sure you want to continue?

    - - ), - }); - } else { - this.props.endSession(this.state.deleteAfterFinish, true); - } - }; - - handleToggleDelete = () => - this.setState({ deleteAfterFinish: !this.state.deleteAfterFinish }); - render() { - return ( -
    -
    -

    - Finish Interview -

    - -
    -

    - You have reached the end of the interview. - If you are satisfied with the information you have entered, you may finish the - interview now. -

    -
    - - -
    -
    -
    + +
    +
    - ); - } -} +
    + ); +}; FinishSession.propTypes = { endSession: PropTypes.func.isRequired, - openDialog: PropTypes.func.isRequired, }; const mapDispatchToProps = { - deleteSession: sessionsActions.removeSession, endSession: sessionActions.endSession, - openDialog: dialogActions.openDialog, }; export default connect(null, mapDispatchToProps)(FinishSession); diff --git a/src/containers/ProtocolScreen.js b/src/containers/ProtocolScreen.js index bf7b4f9f56..ca1ac90d00 100644 --- a/src/containers/ProtocolScreen.js +++ b/src/containers/ProtocolScreen.js @@ -129,7 +129,7 @@ class Protocol extends Component { const duration = { enter: getCSSVariableAsNumber('--animation-duration-slow-ms') * 2, - exit: getCSSVariableAsNumber('--animation-duration-slow-ms'), + exit: getCSSVariableAsNumber('--animation-duration-slow-ms') * 2, }; return ( diff --git a/src/ducks/modules/navigate.js b/src/ducks/modules/navigate.js index c5b73f965f..a450cb455e 100644 --- a/src/ducks/modules/navigate.js +++ b/src/ducks/modules/navigate.js @@ -18,11 +18,11 @@ const isForwards = direction => direction > 0; * Go to the stage at the index provided * @param {number} index Index of the stage in protocol */ -const goToStage = index => +const goToStage = (index, direction) => (dispatch, getState) => { const state = getState(); - - const sessionPath = getSessionPath(state, index); + const back = direction === -1 ? '/?back' : ''; + const sessionPath = `${getSessionPath(state, index)}${back}`; return dispatch(push(sessionPath)); }; @@ -68,7 +68,7 @@ const goToNextStage = (direction = 1) => if (nextIndex === null) { return null; } - return dispatch(goToStage(nextIndex)); + return dispatch(goToStage(nextIndex, direction)); }; /** diff --git a/src/styles/containers/_dyad-census.scss b/src/styles/containers/_dyad-census.scss index 8d09181264..cd71c14de2 100644 --- a/src/styles/containers/_dyad-census.scss +++ b/src/styles/containers/_dyad-census.scss @@ -102,6 +102,7 @@ $choice-padding: 2rem; } &__choice { + border: 0.5rem solid transparent; flex-grow: 0; display: flex; flex-direction: column; @@ -112,7 +113,7 @@ $choice-padding: 2rem; width: 55rem; &--invalid { - outline: 0.5rem solid var(--error); + border-color: var(--error); outline-offset: 0.75rem; animation: shake 0.82s cubic-bezier(.36, .07, .19, .97) both; } @@ -147,10 +148,6 @@ $choice-padding: 2rem; transition: background var(--animation-duration-standard) var(--animation-easing); } - &__options { - height: 9.5rem; - } - &__options-step { display: flex; justify-content: center; @@ -160,6 +157,7 @@ $choice-padding: 2rem; .form-field-togglebutton__checkbox { border-radius: var(--border-radius); width: 12rem; + height: 5rem; &::before { border-radius: 0.75rem; @@ -181,9 +179,9 @@ $choice-padding: 2rem; } &__progress { - padding: unit(1) unit(4); + padding: unit(2) unit(4); width: 42rem; - margin: 1rem auto; + margin: 0 auto; .progress-bar { background-color: var(--transparent-light); From c716678ef55f5fe52ca44d4c149bf07ac81684bf Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 19:39:51 +0100 Subject: [PATCH 50/71] fix drag source effect --- src/behaviours/DragAndDrop/DragSource.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/behaviours/DragAndDrop/DragSource.js b/src/behaviours/DragAndDrop/DragSource.js index e860abe138..800bf7f222 100644 --- a/src/behaviours/DragAndDrop/DragSource.js +++ b/src/behaviours/DragAndDrop/DragSource.js @@ -81,15 +81,15 @@ const dragSource = WrappedComponent => }; useEffect(() => { - if (!node.current || !allowDrag) { return false; } - - dragManager = new DragManager({ - el: node.current, - onDragStart, - onDragMove, - onDragEnd, - scrollDirection, - }); + if (node.current && allowDrag) { + dragManager = new DragManager({ + el: node.current, + onDragStart, + onDragMove, + onDragEnd, + scrollDirection, + }); + } return () => { cleanupPreview(); From c6450e057b5a2f2b6a7f2cf79b3de899035b6ee4 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 20:18:00 +0100 Subject: [PATCH 51/71] increase sociiogram responsiveness --- src/behaviours/DragAndDrop/DragManager.js | 2 +- src/behaviours/DragAndDrop/DragPreview.js | 2 +- src/behaviours/DragAndDrop/DragSource.js | 11 ++++++++--- .../StartScreen/FetchServerProtocolPicker.js | 4 +--- src/styles/components/_draggable-preview.scss | 8 ++++++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/behaviours/DragAndDrop/DragManager.js b/src/behaviours/DragAndDrop/DragManager.js index a72ef7e5c8..6072c7318f 100644 --- a/src/behaviours/DragAndDrop/DragManager.js +++ b/src/behaviours/DragAndDrop/DragManager.js @@ -73,7 +73,7 @@ class dragManager { this.state = { ...initalState }; this.el = el; this.onDragStart = onDragStart; - this.onDragMove = throttle(onDragMove, 1000 / 60); + this.onDragMove = throttle(onDragMove, 1000 / 120); this.onDragEnd = onDragEnd; this.el.addEventListener('touchstart', this.onMoveStart, { passive: true }); this.el.addEventListener('touchmove', this.onMove, { passive: false }); diff --git a/src/behaviours/DragAndDrop/DragPreview.js b/src/behaviours/DragAndDrop/DragPreview.js index e54ac2758e..7e1b791a76 100644 --- a/src/behaviours/DragAndDrop/DragPreview.js +++ b/src/behaviours/DragAndDrop/DragPreview.js @@ -59,7 +59,7 @@ export default class DraggablePreview { if (this.validMove) { this.node.setAttribute('class', 'draggable-preview'); } else { - this.node.setAttribute('class', 'draggable-preview node--inactive'); + this.node.setAttribute('class', 'draggable-preview draggable-preview--invalid'); } } diff --git a/src/behaviours/DragAndDrop/DragSource.js b/src/behaviours/DragAndDrop/DragSource.js index 800bf7f222..ca1f4b1d96 100644 --- a/src/behaviours/DragAndDrop/DragSource.js +++ b/src/behaviours/DragAndDrop/DragSource.js @@ -1,6 +1,7 @@ /* eslint-disable react/no-find-dom-node, react/sort-comp */ import React, { useEffect, useRef, useState } from 'react'; +import { throttle } from 'lodash'; import DragPreview from './DragPreview'; import DragManager, { VERTICAL_SCROLL } from './DragManager'; import { actionCreators as actions } from './reducer'; @@ -45,6 +46,7 @@ const dragSource = WrappedComponent => }; const setValidMove = (valid) => { + if (!preview) return; preview.setValidMove(valid); }; @@ -61,14 +63,17 @@ const dragSource = WrappedComponent => setIsDragging(true); }; - const onDragMove = ({ x, y, ...other }) => { - updatePreview({ x, y }); - + const throttledDragAction = throttle(({ x, y, ...other }) => { store.dispatch( actions.dragMove({ x, y, setValidMove, ...other, }), ); + }, 60); + + const onDragMove = ({ x, y, ...other }) => { + updatePreview({ x, y }); + throttledDragAction({ x, y, ...other }); }; const onDragEnd = (movement) => { diff --git a/src/containers/StartScreen/FetchServerProtocolPicker.js b/src/containers/StartScreen/FetchServerProtocolPicker.js index fcaba322be..d634553778 100644 --- a/src/containers/StartScreen/FetchServerProtocolPicker.js +++ b/src/containers/StartScreen/FetchServerProtocolPicker.js @@ -42,9 +42,7 @@ const FetchServerProtocolPicker = ({ }; useEffect(() => { - if (!show) { return; } - - if (!onlineStatus || !pairedServer) { onClose(); return; } + if (!show || !onlineStatus || !pairedServer) { onClose(); } setLoading(true); diff --git a/src/styles/components/_draggable-preview.scss b/src/styles/components/_draggable-preview.scss index 9aefadd557..6fe48ec120 100644 --- a/src/styles/components/_draggable-preview.scss +++ b/src/styles/components/_draggable-preview.scss @@ -1,10 +1,14 @@ .draggable-preview { z-index: var(--z-fx-sub); - opacity: 0.5; + + &--invalid { + .node { + opacity: 0.5; + } + } .node { transform: translate(-25%, -25%); - position: absolute; } } From 91b44d20ac8a195b12b10a7c48f3e0bc87b079f5 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 23:07:58 +0100 Subject: [PATCH 52/71] separate sass components --- .../StartScreen => components}/Switch.js | 0 src/components/index.js | 1 + .../StartScreen/DataExportSection.js | 3 +- .../StartScreen/FetchServerProtocolPicker.js | 4 +- src/containers/StartScreen/HeaderSection.js | 3 +- src/containers/StartScreen/Section.js | 15 +- src/containers/StartScreen/StartScreen.js | 2 - src/containers/StartScreen/index.js | 1 - src/styles/components/_all.scss | 3 + src/styles/components/_case-id-form.scss | 16 + src/styles/components/_stack-button.scss | 64 ++ src/styles/components/_switch.scss | 56 ++ src/styles/containers/StartScreen/_all.scss | 759 +----------------- .../StartScreen/_data-export-section.scss | 51 ++ .../StartScreen/_export-error-list.scss | 19 + .../StartScreen/_import-section.scss | 41 + .../StartScreen/_interview-section.scss | 61 ++ .../containers/StartScreen/_pairing-form.scss | 39 + .../StartScreen/_protocol-url-form.scss | 14 + .../StartScreen/_server-address-form.scss | 17 + .../StartScreen/_server-section.scss | 175 ++++ .../StartScreen/_start-screen-header.scss | 74 ++ .../StartScreen/_start-screen-section.scss | 17 + .../containers/StartScreen/_start-screen.scss | 22 + .../StartScreen/_welcome-section.scss | 36 + .../StartScreen/_whats-new-section.scss | 16 + 26 files changed, 750 insertions(+), 759 deletions(-) rename src/{containers/StartScreen => components}/Switch.js (100%) create mode 100644 src/styles/components/_case-id-form.scss create mode 100644 src/styles/components/_stack-button.scss create mode 100644 src/styles/components/_switch.scss create mode 100644 src/styles/containers/StartScreen/_data-export-section.scss create mode 100644 src/styles/containers/StartScreen/_export-error-list.scss create mode 100644 src/styles/containers/StartScreen/_import-section.scss create mode 100644 src/styles/containers/StartScreen/_interview-section.scss create mode 100644 src/styles/containers/StartScreen/_pairing-form.scss create mode 100644 src/styles/containers/StartScreen/_protocol-url-form.scss create mode 100644 src/styles/containers/StartScreen/_server-address-form.scss create mode 100644 src/styles/containers/StartScreen/_server-section.scss create mode 100644 src/styles/containers/StartScreen/_start-screen-header.scss create mode 100644 src/styles/containers/StartScreen/_start-screen-section.scss create mode 100644 src/styles/containers/StartScreen/_start-screen.scss create mode 100644 src/styles/containers/StartScreen/_welcome-section.scss create mode 100644 src/styles/containers/StartScreen/_whats-new-section.scss diff --git a/src/containers/StartScreen/Switch.js b/src/components/Switch.js similarity index 100% rename from src/containers/StartScreen/Switch.js rename to src/components/Switch.js diff --git a/src/components/index.js b/src/components/index.js index 21604decec..471965d909 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -7,6 +7,7 @@ export { default as Pips } from './Pips'; export { default as Prompt } from './Prompt'; export { default as Edge } from './Edge'; export { default as NodeList } from './NodeList'; +export { default as Switch } from './Switch'; export { default as MultiNodeBucket } from './MultiNodeBucket'; export { default as Panels } from './Panels'; export { default as Panel } from './Panel'; diff --git a/src/containers/StartScreen/DataExportSection.js b/src/containers/StartScreen/DataExportSection.js index 9816e0f107..c6d1de9842 100644 --- a/src/containers/StartScreen/DataExportSection.js +++ b/src/containers/StartScreen/DataExportSection.js @@ -9,8 +9,7 @@ import { Section } from '.'; import { actionCreators as sessionsActions } from '../../ducks/modules/sessions'; import { actionCreators as dialogActions } from '../../ducks/modules/dialogs'; import useServerConnectionStatus from '../../hooks/useServerConnectionStatus'; -import Switch from './Switch'; -import { NewFilterableListWrapper } from '../../components'; +import { NewFilterableListWrapper, Switch } from '../../components'; import { asNetworkWithSessionVariables } from '../../utils/networkFormat'; const oneBasedIndex = i => parseInt(i || 0, 10) + 1; diff --git a/src/containers/StartScreen/FetchServerProtocolPicker.js b/src/containers/StartScreen/FetchServerProtocolPicker.js index d634553778..3b93019ee8 100644 --- a/src/containers/StartScreen/FetchServerProtocolPicker.js +++ b/src/containers/StartScreen/FetchServerProtocolPicker.js @@ -42,7 +42,9 @@ const FetchServerProtocolPicker = ({ }; useEffect(() => { - if (!show || !onlineStatus || !pairedServer) { onClose(); } + if (!show) return; + + if (!onlineStatus || !pairedServer) { onClose(); } setLoading(true); diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index 20f8481349..a520a02ecc 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -8,7 +8,7 @@ import { actionCreators as deviceSettingsActions } from '../../ducks/modules/dev import { importProtocolFromURI } from '../../utils/protocol/importProtocol'; import { SettingsMenuButton } from '../../components/SettingsMenu'; import { openExternalLink } from '../../components/ExternalLink'; -import Switch from './Switch'; +import { Switch } from '../../components'; import getVersion from '../../utils/getVersion'; import useOnlineStatus from '../../hooks/useOnlineStatus'; @@ -78,7 +78,6 @@ const HeaderSection = () => {
    diff --git a/src/containers/StartScreen/Section.js b/src/containers/StartScreen/Section.js index c925b562ff..386cc966b6 100644 --- a/src/containers/StartScreen/Section.js +++ b/src/containers/StartScreen/Section.js @@ -8,25 +8,24 @@ const Section = (props) => { ...rest } = props; - const itemVariants = { + const springy = { show: { - y: 0, opacity: 1, + y: 0, transition: { type: 'spring', - when: 'beforeChildren', }, }, hide: { - y: '10rem', opacity: 0, + y: '10rem', }, }; return ( {children} @@ -34,4 +33,10 @@ const Section = (props) => { ); }; +Section.propTypes = { +}; + +Section.defaultProps = { +}; + export default Section; diff --git a/src/containers/StartScreen/StartScreen.js b/src/containers/StartScreen/StartScreen.js index 2d43ee8688..7937e44a39 100644 --- a/src/containers/StartScreen/StartScreen.js +++ b/src/containers/StartScreen/StartScreen.js @@ -11,7 +11,6 @@ import { import WhatsNewSection from './WhatsNewSection'; import DataExportSection from './DataExportSection'; - const StartScreen = ({ activeSessionId, sessions, @@ -39,7 +38,6 @@ const StartScreen = ({ variants={variants} animate="show" initial="hide" - layout key="start-screen" > diff --git a/src/containers/StartScreen/index.js b/src/containers/StartScreen/index.js index 5c13d7ff85..ce473dfd7c 100644 --- a/src/containers/StartScreen/index.js +++ b/src/containers/StartScreen/index.js @@ -4,4 +4,3 @@ export { default as InterviewSection } from './InterviewSection'; export { default as ImportSection } from './ImportSection'; export { default as HeaderSection } from './HeaderSection'; export { default as ServerSection } from './ServerSection'; -export { default as Switch } from './Switch'; diff --git a/src/styles/components/_all.scss b/src/styles/components/_all.scss index e8e36fe65b..d4d5a7276c 100644 --- a/src/styles/components/_all.scss +++ b/src/styles/components/_all.scss @@ -35,6 +35,9 @@ @import 'list-select'; @import 'information-interface'; @import 'ordinal-bins'; +@import 'switch'; +@import 'case-id-form'; +@import 'stack-button'; // Session Panel @import './session-panel/all'; diff --git a/src/styles/components/_case-id-form.scss b/src/styles/components/_case-id-form.scss new file mode 100644 index 0000000000..0fdd9b6fbd --- /dev/null +++ b/src/styles/components/_case-id-form.scss @@ -0,0 +1,16 @@ +.case-id-form { + width: 60rem; + + .form-field-container { + background: var(--background); + padding: unit(4); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/src/styles/components/_stack-button.scss b/src/styles/components/_stack-button.scss new file mode 100644 index 0000000000..d7ab74e45d --- /dev/null +++ b/src/styles/components/_stack-button.scss @@ -0,0 +1,64 @@ +.stack-button { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + color: white; + height: 100%; + cursor: pointer; + + &__label { + margin: 0; + padding-bottom: 1.2rem; + } + + &__content { + color: var(--text-dark); + position: absolute; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + margin-left: -0.5rem; + z-index: var(--z-default); + } + + + .stack-of-cards { + width: 14rem; + height: 9rem; + display: flex; + align-items: flex-end; + position: relative; + + .mock-card { + position: absolute; + width: 13rem; + height: 8rem; + border-radius: var(--border-radius); + background: var(--color-slate-blue--dark); + box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.3); + overflow: hidden; + + + .main { + width: 100%; + height: 5rem; + } + + &--two { + margin-top: -0.5rem; + margin-left: 0.5rem; + opacity: 0.65; + + } + + &--three { + margin-top: -1rem; + margin-left: 1rem; + opacity: 0.35; + } + } + } +} \ No newline at end of file diff --git a/src/styles/components/_switch.scss b/src/styles/components/_switch.scss new file mode 100644 index 0000000000..1d5e3c2fd5 --- /dev/null +++ b/src/styles/components/_switch.scss @@ -0,0 +1,56 @@ +.switch { + $size: 2rem; + position: relative; + display: inline-flex; + align-items: center; + + &__button { + cursor: pointer; + width: $size * 2; + height: $size; + position: relative; + + &::before { + position: absolute; + top: 50%; + transform: translateY(-50%); + content: ''; + border-radius: $size; + background-color: var(--color-rich-black); + transition: background var(--animation-duration-fast) var(--animation-easing); + width: $size * 2; + height: $size * .67; + } + + &::after { + position: absolute; + content: ''; + border-radius: 50%; + height: $size; + width: $size; + background-color: var(--color-white); + transform: translateX(-1px); + transition: transform var(--animation-duration-fast) var(--animation-easing); + } + } + + &__input { + visibility: hidden; + position: absolute; + + &:checked + .switch__button { + &::before { + background-color: var(--input-radio--fill); + } + + &::after { + transform: translateX(calc(#{$size} + 1px)); + } + } + } + + &__label { + margin-left: 1rem; + font-family: var(--heading-font-family); + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_all.scss b/src/styles/containers/StartScreen/_all.scss index e2e8fea950..178d6e2781 100644 --- a/src/styles/containers/StartScreen/_all.scss +++ b/src/styles/containers/StartScreen/_all.scss @@ -1,746 +1,13 @@ -.start-screen { - --color-panel-grey: #DCDEDD; - background-color: var(--background); - background-image: url('../images/main-background.svg'); - background-repeat: no-repeat; - background-position: center; - background-attachment: scroll; - - -webkit-mask-image: linear-gradient(180deg, transparent, black 1.2rem, black calc(100% - 2rem), transparent 100%); - overflow-x: hidden; - overflow-y: auto; - -webkit-overflow-scrolling: touch; - scroll-behavior: smooth; - height: 100%; - width: 100%; - position: absolute; - top: 0; - - &__container { - padding: unit(2) unit(8) unit(8); - } -} - -.start-screen-section { - border-radius: var(--border-radius); - box-shadow: 0 unit(1) unit(1) 0 rgba(0, 0, 0, 0.25); - margin: unit(6) auto; - max-width: 68rem; - width: 100%; - overflow: hidden; - - main { - header { - h2 { - margin-top: 0; - } - } - } - -} - -.welcome-section { - margin-top: 0; - color: var(--white); - background-color: var(--color-cyber-grape); - width: 100%; - background-image: url('../images/getting-started.svg'); - background-size: 50%; - background-repeat: no-repeat; - background-position: 100% 0; - position: relative; - - &__main { - padding: 0 unit(6) unit(4); - } - - .welcome-description { - width: 60%; - } - - .welcome-actions { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: unit(4); - - > .button { - flex: 1; - max-width: 32%; - - .button__content { - padding: 0; - font-size: 0.75rem; - } - } - } -} - -.interview-section { - background: linear-gradient(#404088, var(--color-cyber-grape)); - &__manage-protocols { - padding: unit(2) unit(6); - display: flex; - align-items: center; - justify-content: flex-end; - - h2 { - margin: 0; - } - } - - &__start-new { - padding: 0 !important; - - .content-area { - display: flex; - - &__last-used { - padding: unit(4) unit(6); - flex: 1 1 auto; - - > .protocol-card { - max-width: 35rem; - } - } - - &__other { - flex: 0 1 40%; - display: flex; - flex-direction: column; - } - } - } - - &__resume-section { - padding: 0 !important; - background: rgba(22, 21, 43, 0.14901960784313725); - - .content-area { - display: flex; - - &__last-session { - padding: unit(4) unit(6); - flex: 1 1 auto; - width: 60%; - - > .session-card { - max-width: 35rem; - } - } - - &__other { - flex: 0 1 40%; - display: flex; - flex-direction: column; - } - } - } -} - -.import-section { - background: linear-gradient(#404088, var(--color-cyber-grape)); - - &__manage-protocols { - background: var(--color-slate-blue); - padding: unit(2) unit(6); - display: flex; - align-items: center; - justify-content: flex-end; - - h2 { - margin: 0; - } - } - - &__install-section { - padding: unit(4) unit(6); - - > .content-buttons { - display: flex; - justify-content: space-around; - - .graphic-button { - max-width: 30rem; - } - - > :only-child { - max-width: 30rem; - margin-left: -50% !important; - } - - > :first-child { - margin-right: unit(2); - } - - > :last-child { - margin-left: unit(2); - } - } - } -} - -.start-screen-header { - color: var(--white); - background: var(--color-cyber-grape); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - position: relative; - margin: unit(4) auto; - overflow: hidden; - border-radius: var(--border-radius); - - .version-string { - font-weight: bolder; - font-size: 0.64rem; - letter-spacing: .15em; - align-self: flex-start; - margin-left: 0rem; - margin-top: 1rem; - } - - &__top { - display: flex; - background: rgb(64, 64, 136); - flex: 1; - width: 100%; - align-items: center; - justify-content: space-between; - padding: unit(2) unit(4); - - .header-toggle { - .switch__label { - font-size: 0.8rem; - } - } - } - - &__wrapper { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - padding: unit(2) unit(4) unit(4); - background: linear-gradient(#404088, transparent); - - .header-brand { - flex: 0 0 unit(15); - z-index: var(--z-global-ui); - - img { - filter: drop-shadow(0 unit(1) unit(1) rgba(0, 0, 0, 0.35)); - } - } - - .header-mark { - flex: 0 0 auto; - margin-right: unit(2); - - h1 { - font-size: 3.052rem; - letter-spacing: .15em; - margin: 0; - margin-bottom: unit(1); - } - - h4 { - font-size: 1.25rem; - font-weight: lighter; - margin: 0; - } - } - } - -} - -.action-button { - padding: unit(2); - background: var(--primary); - color: var(--white); - border-radius: var(--border-radius); - } - -.switch { - $size: 2rem; - position: relative; - display: inline-flex; - align-items: center; - - &__button { - cursor: pointer; - width: $size * 2; - height: $size; - position: relative; - - &::before { - position: absolute; - top: 50%; - transform: translateY(-50%); - content: ''; - border-radius: $size; - background-color: var(--color-rich-black); - transition: background var(--animation-duration-fast) var(--animation-easing); - width: $size * 2; - height: $size * .67; - } - - &::after { - position: absolute; - content: ''; - border-radius: 50%; - height: $size; - width: $size; - background-color: var(--color-white); - transform: translateX(-1px); - transition: transform var(--animation-duration-fast) var(--animation-easing); - } - } - - &__input { - visibility: hidden; - position: absolute; - - &:checked + .switch__button { - &::before { - background-color: var(--input-radio--fill); - } - - &::after { - transform: translateX(calc(#{$size} + 1px)); - } - } - } - - &__label { - margin-left: 1rem; - font-family: var(--heading-font-family); - } -} - - - -.server-section { - &__main { - background: linear-gradient(#404088, var(--color-cyber-grape)); - padding: 0; - - .content-area { - display: flex; - flex-direction: column; - - &__buttons { - padding: unit(2) unit(6); - background: var(--color-mustard); - display: flex; - align-items: center; - justify-content: space-between; - } - - &__discover { - padding: unit(4) unit(6); - - > h4 { - margin-top: 0; - margin-bottom: 1.3rem; - } - - .paired-server-wrapper { - margin-top: unit(2); - padding: unit(2) unit(4); - background: var(--color-slate-blue--dark); - color: var(--color-white); - border-radius: var(--border-radius); - - .server-card { - margin: unit(2); - } - } - - .discovered-server-list { - background: var(--color-slate-blue); - border-radius: var(--border-radius); - max-height: 30rem; - min-height: 25rem; - display: flex; - flex-direction: column; - justify-content: center; - overflow: hidden; - margin-top: unit(2); - - &--offline, - &--error { - min-height: unset; - } - - header { - width: 100%; - display: flex; - background: var(--color-slate-blue--dark); - color: var(--color-white); - align-items: center; - padding: unit(1) unit(4); - - .spinner--small { - --circle-size: 0.5rem; - margin-right: unit(2); - } - } - - .scrollable { - display: flex; - flex-wrap: wrap; - padding: unit(2); - - > * { - max-width: 50%; - } - - &:empty { - display: flex; - - &::after { - width: 100%; - height: 10rem; - display: flex; - justify-content: center; - align-items: center; - color: var(--color-white); - content: 'No Servers found.'; - } - } - } - - .server-list { - display: flex; - - &--error, - &--offline { - padding: unit(2); - - .error__icon { - flex: 0 0 10rem; - display: flex; - justify-content: center; - align-items: center; - } - } - } - } - } - } - - } -} - -.server-address-form { - width: 60rem; - - &__form { - padding: unit(4); - background: var(--background); - border-radius: var(--border-radius); - margin-bottom: unit(2); - } - - &__footer { - display: flex; - align-items: center; - justify-content: space-between; - flex-direction: row-reverse; - } - -} - -.pairing-form { - - &--loading { - display: flex; - background: var(--background); - padding: unit(4); - align-items: center; - justify-content: center; - flex-direction: column; - border-radius: var(--border-radius); - } - - &__footer { - display: flex; - align-items: center; - justify-content: space-between; - - .pairing-code-clear { - color: var(--color-platinum--dark); - cursor: pointer; - font-weight: 700; - font-size: 0.8rem; - letter-spacing: 0.15em; // match button - text-transform: uppercase; - - &:hover { - color: var(--color-white); - } - } - - .pairing-form-buttons { - .button { - &:last-child { - margin-left: unit(2); - } - } - } - } -} - -.protocol-url-form { - .form-field-container { - background: var(--background); - padding: unit(4); - border-radius: var(--border-radius); - margin-bottom: unit(2); - } - - &__footer { - display: flex; - align-items: center; - justify-content: flex-end; - } -} - -.case-id-form { - width: 60rem; - - .form-field-container { - background: var(--background); - padding: unit(4); - border-radius: var(--border-radius); - margin-bottom: unit(2); - } - - &__footer { - display: flex; - align-items: center; - justify-content: flex-end; - } -} - - -.stack-button { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - color: white; - height: 100%; - cursor: pointer; - - &__label { - margin: 0; - padding-bottom: 1.2rem; - } - - &__content { - color: var(--text-dark); - position: absolute; - height: 100%; - width: 100%; - display: flex; - align-items: center; - justify-content: center; - margin-left: -0.5rem; - z-index: var(--z-default); - } - - - .stack-of-cards { - width: 14rem; - height: 9rem; - display: flex; - align-items: flex-end; - position: relative; - - .mock-card { - position: absolute; - width: 13rem; - height: 8rem; - border-radius: var(--border-radius); - background: var(--color-slate-blue--dark); - box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.3); - overflow: hidden; - - - .main { - width: 100%; - height: 5rem; - } - - &--two { - margin-top: -0.5rem; - margin-left: 0.5rem; - opacity: 0.65; - - } - - &--three { - margin-top: -1rem; - margin-left: 1rem; - opacity: 0.35; - } - } - } -} - - -.server-status { - display: flex; - - &__indicator { - display: flex; - align-items: center; - justify-content: center; - padding: 0 unit(2); - - .indicator { - width: unit(2); - height: unit(2); - border-radius: 50%; - background: var(--error); - } - } - - &--error { - .server-status__indicator { - .indicator { - background: var(--error); - @keyframes blinker { - 50% { - opacity: 0; - } - } - animation: blinker 1s cubic-bezier(1, 0, 0, 1) infinite; - } - } - } - - &--caution { - .server-status__indicator { - .indicator { - background: var(--warning); - } - } - } - - &--ok { - .server-status__indicator { - .indicator { - background: var(--color-sea-green); - @keyframes pulse { - // sass-lint:disable-block no-color-literals - 0% { - box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0.8); - } - - 70% { - box-shadow: 0 0 0 2rem rgba(var(--color-sea-green---rgb), 0); - } - - 100% { - box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0); - } - } - - animation: pulse 2s infinite; - } - } - } -} - -.whats-new-section { - background: var(--color-sea-serpent); - color: var(--color-white); - - &__content { - padding: unit(4) unit(6); - } - - &__footer { - background: var(--color-sea-serpent--dark); - padding: unit(2) unit(6); - display: flex; - align-items: center; - justify-content: flex-end; - } -} - -.data-export-section { - background: linear-gradient(#404088, var(--color-cyber-grape)); - - &__main { - > header { - padding: unit(4) unit(6) 0; - } - - .content-area { - padding: unit(1) unit(6); - } - - .selection-status { - margin: unit(2) unit(4); - display: flex; - justify-content: space-between; - align-items: center; - flex-direction: row-reverse; - height: unit(6) - } - - .new-filterable-list { - margin: unit(4); - margin-bottom: 0; - - &__main { - max-height: 45vh; - min-height: 16rem; - } - } - } - - &__footer { - padding: unit(2) unit(6); - background-color: var(--color-neon-coral); - display: flex; - justify-content: space-between; - - .action-buttons { - display: flex; - align-items: center; - justify-content: flex-end; - - .button { - &:last-child { - margin-left: unit(2); - } - } - } - } -} - -.dialog { - .export-error-list { - margin: 0; - padding: 0; - - li { - list-style: none; - display: flex; - align-items: center; - margin: 0.75rem; - - .icon { - height: unit(3); - width: unit(3); - margin-right: unit(2); - } - } - } -} \ No newline at end of file +@import 'start-screen'; +@import 'start-screen-section'; +@import 'welcome-section'; +@import 'interview-section'; +@import 'import-section'; +@import 'start-screen-header'; +@import 'server-section'; +@import 'server-address-form'; +@import 'pairing-form'; +@import 'protocol-url-form'; +@import 'whats-new-section'; +@import 'data-export-section'; +@import 'export-error-list'; \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_data-export-section.scss b/src/styles/containers/StartScreen/_data-export-section.scss new file mode 100644 index 0000000000..3328e64881 --- /dev/null +++ b/src/styles/containers/StartScreen/_data-export-section.scss @@ -0,0 +1,51 @@ +.data-export-section { + background: linear-gradient(#404088, var(--color-cyber-grape)); + + &__main { + > header { + padding: unit(4) unit(6) 0; + } + + .content-area { + padding: unit(1) unit(6); + } + + .selection-status { + margin: unit(2) unit(4); + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row-reverse; + height: unit(6) + } + + .new-filterable-list { + margin: unit(4); + margin-bottom: 0; + + &__main { + max-height: 45vh; + min-height: 16rem; + } + } + } + + &__footer { + padding: unit(2) unit(6); + background-color: var(--color-neon-coral); + display: flex; + justify-content: space-between; + + .action-buttons { + display: flex; + align-items: center; + justify-content: flex-end; + + .button { + &:last-child { + margin-left: unit(2); + } + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_export-error-list.scss b/src/styles/containers/StartScreen/_export-error-list.scss new file mode 100644 index 0000000000..fc4f1e9911 --- /dev/null +++ b/src/styles/containers/StartScreen/_export-error-list.scss @@ -0,0 +1,19 @@ +.dialog { + .export-error-list { + margin: 0; + padding: 0; + + li { + list-style: none; + display: flex; + align-items: center; + margin: 0.75rem; + + .icon { + height: unit(3); + width: unit(3); + margin-right: unit(2); + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_import-section.scss b/src/styles/containers/StartScreen/_import-section.scss new file mode 100644 index 0000000000..58d04be55f --- /dev/null +++ b/src/styles/containers/StartScreen/_import-section.scss @@ -0,0 +1,41 @@ +.import-section { + background: linear-gradient(#404088, var(--color-cyber-grape)); + + &__manage-protocols { + background: var(--color-slate-blue); + padding: unit(2) unit(6); + display: flex; + align-items: center; + justify-content: flex-end; + + h2 { + margin: 0; + } + } + + &__install-section { + padding: unit(4) unit(6); + + > .content-buttons { + display: flex; + justify-content: space-around; + + .graphic-button { + max-width: 30rem; + } + + > :only-child { + max-width: 30rem; + margin-left: -50% !important; + } + + > :first-child { + margin-right: unit(2); + } + + > :last-child { + margin-left: unit(2); + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_interview-section.scss b/src/styles/containers/StartScreen/_interview-section.scss new file mode 100644 index 0000000000..fb5459e069 --- /dev/null +++ b/src/styles/containers/StartScreen/_interview-section.scss @@ -0,0 +1,61 @@ +.interview-section { + background: linear-gradient(#404088, var(--color-cyber-grape)); + &__manage-protocols { + padding: unit(2) unit(6); + display: flex; + align-items: center; + justify-content: flex-end; + + h2 { + margin: 0; + } + } + + &__start-new { + padding: 0 !important; + + .content-area { + display: flex; + + &__last-used { + padding: unit(4) unit(6); + flex: 1 1 auto; + + > .protocol-card { + max-width: 35rem; + } + } + + &__other { + flex: 0 1 40%; + display: flex; + flex-direction: column; + } + } + } + + &__resume-section { + padding: 0 !important; + background: rgba(22, 21, 43, 0.14901960784313725); + + .content-area { + display: flex; + + &__last-session { + padding: unit(4) unit(6); + flex: 1 1 auto; + width: 60%; + + > .session-card { + max-width: 35rem; + } + } + + &__other { + flex: 0 1 40%; + display: flex; + flex-direction: column; + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_pairing-form.scss b/src/styles/containers/StartScreen/_pairing-form.scss new file mode 100644 index 0000000000..f7ddfac6aa --- /dev/null +++ b/src/styles/containers/StartScreen/_pairing-form.scss @@ -0,0 +1,39 @@ +.pairing-form { + + &--loading { + display: flex; + background: var(--background); + padding: unit(4); + align-items: center; + justify-content: center; + flex-direction: column; + border-radius: var(--border-radius); + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + + .pairing-code-clear { + color: var(--color-platinum--dark); + cursor: pointer; + font-weight: 700; + font-size: 0.8rem; + letter-spacing: 0.15em; // match button + text-transform: uppercase; + + &:hover { + color: var(--color-white); + } + } + + .pairing-form-buttons { + .button { + &:last-child { + margin-left: unit(2); + } + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_protocol-url-form.scss b/src/styles/containers/StartScreen/_protocol-url-form.scss new file mode 100644 index 0000000000..00183b980c --- /dev/null +++ b/src/styles/containers/StartScreen/_protocol-url-form.scss @@ -0,0 +1,14 @@ +.protocol-url-form { + .form-field-container { + background: var(--background); + padding: unit(4); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: flex-end; + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_server-address-form.scss b/src/styles/containers/StartScreen/_server-address-form.scss new file mode 100644 index 0000000000..b2ed93a733 --- /dev/null +++ b/src/styles/containers/StartScreen/_server-address-form.scss @@ -0,0 +1,17 @@ +.server-address-form { + + &__form { + padding: unit(4); + background: var(--background); + border-radius: var(--border-radius); + margin-bottom: unit(2); + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row-reverse; + } + +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_server-section.scss b/src/styles/containers/StartScreen/_server-section.scss new file mode 100644 index 0000000000..4dfac1c80a --- /dev/null +++ b/src/styles/containers/StartScreen/_server-section.scss @@ -0,0 +1,175 @@ +.server-section { + &__main { + background: linear-gradient(#404088, var(--color-cyber-grape)); + padding: 0; + + .content-area { + display: flex; + flex-direction: column; + + &__buttons { + padding: unit(2) unit(6); + background: var(--color-mustard); + display: flex; + align-items: center; + justify-content: space-between; + } + + &__discover { + padding: unit(4) unit(6); + + > h4 { + margin-top: 0; + margin-bottom: 1.3rem; + } + + .paired-server-wrapper { + margin-top: unit(2); + padding: unit(2) unit(4); + background: var(--color-slate-blue--dark); + color: var(--color-white); + border-radius: var(--border-radius); + + .server-card { + margin: unit(2); + } + } + + .discovered-server-list { + background: var(--color-slate-blue); + border-radius: var(--border-radius); + max-height: 30rem; + min-height: 25rem; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + margin-top: unit(2); + + &--offline, + &--error { + min-height: unset; + } + + header { + width: 100%; + display: flex; + background: var(--color-slate-blue--dark); + color: var(--color-white); + align-items: center; + padding: unit(1) unit(4); + + .spinner--small { + --circle-size: 0.5rem; + margin-right: unit(2); + } + } + + .scrollable { + display: flex; + flex-wrap: wrap; + padding: unit(2); + + > * { + max-width: 50%; + } + + &:empty { + display: flex; + + &::after { + width: 100%; + height: 10rem; + display: flex; + justify-content: center; + align-items: center; + color: var(--color-white); + content: 'No Servers found.'; + } + } + } + + .server-list { + display: flex; + + &--error, + &--offline { + padding: unit(2); + + .error__icon { + flex: 0 0 10rem; + display: flex; + justify-content: center; + align-items: center; + } + } + } + } + } + } + } + + .server-status { + display: flex; + + &__indicator { + display: flex; + align-items: center; + justify-content: center; + padding: 0 unit(2); + + .indicator { + width: unit(2); + height: unit(2); + border-radius: 50%; + background: var(--error); + } + } + + &--error { + .server-status__indicator { + .indicator { + background: var(--error); + @keyframes blinker { + 50% { + opacity: 0; + } + } + animation: blinker 1s cubic-bezier(1, 0, 0, 1) infinite; + } + } + } + + &--caution { + .server-status__indicator { + .indicator { + background: var(--warning); + } + } + } + + &--ok { + .server-status__indicator { + .indicator { + background: var(--color-sea-green); + @keyframes pulse { + // sass-lint:disable-block no-color-literals + 0% { + box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0.8); + } + + 70% { + box-shadow: 0 0 0 2rem rgba(var(--color-sea-green---rgb), 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(var(--color-sea-green---rgb), 0); + } + } + + animation: pulse 2s infinite; + } + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_start-screen-header.scss b/src/styles/containers/StartScreen/_start-screen-header.scss new file mode 100644 index 0000000000..8e85dac032 --- /dev/null +++ b/src/styles/containers/StartScreen/_start-screen-header.scss @@ -0,0 +1,74 @@ +.start-screen-header { + color: var(--white); + background: var(--color-cyber-grape); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + margin: unit(4) auto; + overflow: hidden; + border-radius: var(--border-radius); + + .version-string { + font-weight: bolder; + font-size: 0.64rem; + letter-spacing: .15em; + align-self: flex-start; + margin-left: 0rem; + margin-top: 1rem; + } + + &__top { + display: flex; + background: rgb(64, 64, 136); + flex: 1; + width: 100%; + align-items: center; + justify-content: space-between; + padding: unit(2) unit(4); + + .header-toggle { + .switch__label { + font-size: 0.8rem; + } + } + } + + &__wrapper { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + padding: unit(2) unit(4) unit(4); + background: linear-gradient(#404088, transparent); + + .header-brand { + flex: 0 0 unit(15); + z-index: var(--z-global-ui); + + img { + filter: drop-shadow(0 unit(1) unit(1) rgba(0, 0, 0, 0.35)); + } + } + + .header-mark { + flex: 0 0 auto; + margin-right: unit(2); + + h1 { + font-size: 3.052rem; + letter-spacing: .15em; + margin: 0; + margin-bottom: unit(1); + } + + h4 { + font-size: 1.25rem; + font-weight: lighter; + margin: 0; + } + } + } + +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_start-screen-section.scss b/src/styles/containers/StartScreen/_start-screen-section.scss new file mode 100644 index 0000000000..a2d21a5402 --- /dev/null +++ b/src/styles/containers/StartScreen/_start-screen-section.scss @@ -0,0 +1,17 @@ +.start-screen-section { + border-radius: var(--border-radius); + box-shadow: 0 unit(1) unit(1) 0 rgba(0, 0, 0, 0.25); + margin: unit(6) auto; + max-width: 68rem; + width: 100%; + overflow: hidden; + + main { + header { + h2 { + margin-top: 0; + } + } + } + +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_start-screen.scss b/src/styles/containers/StartScreen/_start-screen.scss new file mode 100644 index 0000000000..decccb5ea0 --- /dev/null +++ b/src/styles/containers/StartScreen/_start-screen.scss @@ -0,0 +1,22 @@ +.start-screen { + --color-panel-grey: #DCDEDD; + background-color: var(--background); + background-image: url('../images/main-background.svg'); + background-repeat: no-repeat; + background-position: center; + background-attachment: scroll; + + -webkit-mask-image: linear-gradient(180deg, transparent, black 1.2rem, black calc(100% - 2rem), transparent 100%); + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + height: 100%; + width: 100%; + position: absolute; + top: 0; + + &__container { + padding: unit(2) unit(8) unit(8); + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_welcome-section.scss b/src/styles/containers/StartScreen/_welcome-section.scss new file mode 100644 index 0000000000..3358cf8812 --- /dev/null +++ b/src/styles/containers/StartScreen/_welcome-section.scss @@ -0,0 +1,36 @@ +.welcome-section { + margin-top: 0; + color: var(--white); + background-color: var(--color-cyber-grape); + width: 100%; + background-image: url('../images/getting-started.svg'); + background-size: 50%; + background-repeat: no-repeat; + background-position: 100% 0; + position: relative; + + &__main { + padding: 0 unit(6) unit(4); + } + + .welcome-description { + width: 60%; + } + + .welcome-actions { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: unit(4); + + > .button { + flex: 1; + max-width: 32%; + + .button__content { + padding: 0; + font-size: 0.75rem; + } + } + } +} \ No newline at end of file diff --git a/src/styles/containers/StartScreen/_whats-new-section.scss b/src/styles/containers/StartScreen/_whats-new-section.scss new file mode 100644 index 0000000000..e2c57c29b9 --- /dev/null +++ b/src/styles/containers/StartScreen/_whats-new-section.scss @@ -0,0 +1,16 @@ +.whats-new-section { + background: var(--color-sea-serpent); + color: var(--color-white); + + &__content { + padding: unit(4) unit(6); + } + + &__footer { + background: var(--color-sea-serpent--dark); + padding: unit(2) unit(6); + display: flex; + align-items: center; + justify-content: flex-end; + } +} \ No newline at end of file From 3171c36a68f873477b010b195aad6f1a240744a7 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 23:30:48 +0100 Subject: [PATCH 53/71] fix welcome section animation --- src/containers/StartScreen/HeaderSection.js | 5 +++-- src/containers/StartScreen/Section.js | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/containers/StartScreen/HeaderSection.js b/src/containers/StartScreen/HeaderSection.js index a520a02ecc..74d960545e 100644 --- a/src/containers/StartScreen/HeaderSection.js +++ b/src/containers/StartScreen/HeaderSection.js @@ -33,7 +33,7 @@ const HeaderSection = () => { }, }, hide: { - height: 0, + height: '0px', opacity: 0, }, }; @@ -45,7 +45,7 @@ const HeaderSection = () => { transition: { when: 'beforeChildren', type: 'spring' }, }, hide: { - height: 0, + height: '0px', opacity: 0, }, }; @@ -78,6 +78,7 @@ const HeaderSection = () => {
    diff --git a/src/containers/StartScreen/Section.js b/src/containers/StartScreen/Section.js index 386cc966b6..876b0665ad 100644 --- a/src/containers/StartScreen/Section.js +++ b/src/containers/StartScreen/Section.js @@ -14,11 +14,12 @@ const Section = (props) => { y: 0, transition: { type: 'spring', + when: 'beforeChildren', }, }, hide: { opacity: 0, - y: '10rem', + y: '5rem', }, }; From b8586ad37cfee3b4efb2bd1f20abc7e981da6f5d Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 29 Sep 2020 23:51:36 +0100 Subject: [PATCH 54/71] tweak form styles --- src/containers/Interfaces/FinishSession.js | 17 +++++++---------- src/styles/components/_case-id-form.scss | 1 - src/styles/containers/_dyad-census.scss | 2 ++ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/containers/Interfaces/FinishSession.js b/src/containers/Interfaces/FinishSession.js index 539e948a55..38bf5bab8e 100644 --- a/src/containers/Interfaces/FinishSession.js +++ b/src/containers/Interfaces/FinishSession.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Button } from '@codaco/ui'; import { actionCreators as sessionActions } from '../../ducks/modules/session'; -import Scroller from '../../components/Scroller'; const FinishSession = ({ endSession }) => { const handleFinishSession = () => { @@ -16,15 +15,13 @@ const FinishSession = ({ endSession }) => {

    Finish Interview

    - -
    -

    - You have reached the end of the interview. - If you are satisfied with the information you have entered, you may finish the - interview now. -

    -
    -
    +
    +

    + You have reached the end of the interview. + If you are satisfied with the information you have entered, you may finish the + interview now. +

    +
    @@ -48,11 +50,13 @@ SlideFormNode.propTypes = { subject: PropTypes.object.isRequired, item: PropTypes.object.isRequired, onUpdate: PropTypes.func, + submitButton: PropTypes.object, }; SlideFormNode.defaultProps = { form: {}, onUpdate: () => {}, + submitButton: