Skip to content

Commit

Permalink
feat: add visual feedback on API address change (#1671)
Browse files Browse the repository at this point in the history
* Add error text to API address settings

* Populate error message for invalid addresses and connection errors

* Add outline to ApiAddressForm to indicate address validity

* Add apiAddress to "could not connect" message

* Add "pending first API connection" handlers to ipfs-provider

* Add full page loader when pending first connection

* Remove custom error CSS, instead use Notify for errors

* Feedback from @rafaelramalho19 - Use arrow function

* Feedback from @jessicashilling and @rafaelramalho19
- Use notify/snackbar for errors and success messages.
- Use notify error message that have translations

* Remove connectionError action in ipfs-provider.
Opt to use notify instead

* Remove unused icon, comment

* Fix bug that shows success message before updating API address

* Fix formatting

* Remove unused "dispatch"

* Add custom error messages for connecting to a new IPFS API

* IPFS_CONNECT_SUCCEED/FAILED set fail state in ipfs-provider

* Return result of API address update in doUpdateIpfsApiAddress

* Add ipfsInvalidApiAddress to locales/en/notify.json and notify.js

* Refocus on input if the API address failed to update

* Show green/red border for valid/invalid API address or red border for invalid api address

* Change ApiAddressForm to more closely follow SelectPeer
- Shows fail/success border without being selected
- Does not refocus if it fails to connect

* Clean up unused code and comments

* Update comments

* Remove useRef from imports

* Disables button with an invalid multiaddr and display red border when
it fails to connect.
When entering in another address, it will refresh the fail state on the
input

* Follow formatting

* Feedback from @rafaelramalho19
- Use triple equals
  • Loading branch information
jack-michaud authored Oct 29, 2020
1 parent 0314595 commit e2e2edd
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 21 deletions.
3 changes: 3 additions & 0 deletions public/locales/en/notify.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"ipfsApiRequestFailed": "Could not connect. Please check if your daemon is running.",
"windowIpfsRequestFailed": "IPFS request failed. Please check your IPFS Companion settings.",
"ipfsInvalidApiAddress": "The provided IPFS API address is invalid.",
"ipfsConnectSuccess": "Successfully connected to the IPFS API address",
"ipfsConnectFail": "Unable to connect to the provided IPFS API address",
"ipfsIsBack": "Normal IPFS service has resumed. Enjoy!",
"folderExists": "An item with that name already exists. Please choose another.",
"filesFetchFailed": "Failed to get those files. Please check the path and try again.",
Expand Down
103 changes: 88 additions & 15 deletions src/bundles/ipfs-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { perform } from './task'
* @property {boolean} failed
* @property {boolean} ready
* @property {boolean} invalidAddress
* @property {boolean} pendingFirstConnection
*
*
* @typedef {import('./task').Perform<'IPFS_INIT', Error, InitResult, void>} Init
* @typedef {Object} Stopped
Expand All @@ -34,19 +36,37 @@ import { perform } from './task'
* @typedef {Object} Dismiss
* @property {'IPFS_API_ADDRESS_INVALID_DISMISS'} type
*
* @typedef {Object} ConnectSuccess
* @property {'IPFS_CONNECT_SUCCEED'} type
*
* @typedef {Object} ConnectFail
* @property {'IPFS_CONNECT_FAILED'} type
*
* @typedef {Object} DismissError
* @property {'NOTIFY_DISMISSED'} type
*
* @typedef {Object} PendingFirstConnection
* @property {'IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION'} type
* @property {boolean} pending
*
* @typedef {Object} InitResult
* @property {ProviderName} provider
* @property {IPFSService} ipfs
* @property {string} [apiAddress]
* @typedef {Init|Stopped|AddressUpdated|AddressInvalid|Dismiss} Message
* @typedef {Init|Stopped|AddressUpdated|AddressInvalid|Dismiss|PendingFirstConnection|ConnectFail|ConnectSuccess|DismissError} Message
*/

export const ACTIONS = Enum.from([
'IPFS_INIT',
'IPFS_STOPPED',
'IPFS_API_ADDRESS_UPDATED',
'IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION',
'IPFS_API_ADDRESS_INVALID',
'IPFS_API_ADDRESS_INVALID_DISMISS'
'IPFS_API_ADDRESS_INVALID_DISMISS',
// Notifier actions
'IPFS_CONNECT_FAILED',
'IPFS_CONNECT_SUCCEED',
'NOTIFY_DISMISSED',
])

/**
Expand Down Expand Up @@ -99,6 +119,16 @@ const update = (state, message) => {
case ACTIONS.IPFS_API_ADDRESS_INVALID_DISMISS: {
return { ...state, invalidAddress: true }
}
case ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION: {
const { pending } = message
return { ...state, pendingFirstConnection: pending }
}
case ACTIONS.IPFS_CONNECT_SUCCEED: {
return { ...state, failed: false }
}
case ACTIONS.IPFS_CONNECT_FAILED: {
return { ...state, failed: true }
}
default: {
return state
}
Expand All @@ -114,7 +144,8 @@ const init = () => {
provider: null,
failed: false,
ready: false,
invalidAddress: false
invalidAddress: false,
pendingFirstConnection: false
}
}

Expand All @@ -126,6 +157,14 @@ const readAPIAddressSetting = () => {
return setting == null ? null : asAPIOptions(setting)
}

/**
* @param {string|object} value
* @returns {boolean}
*/
export const checkValidAPIAddress = (value) => {
return asAPIOptions(value) != null
}

/**
* @param {string|object} value
* @returns {HTTPClientOptions|string|null}
Expand Down Expand Up @@ -297,7 +336,11 @@ const selectors = {
/**
* @param {State} state
*/
selectIpfsInitFailed: state => state.ipfs.failed
selectIpfsInitFailed: state => state.ipfs.failed,
/**
* @param {State} state
*/
selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection,
}

/**
Expand All @@ -308,16 +351,17 @@ const selectors = {

const actions = {
/**
* @returns {function(Context):Promise<void>}
* @returns {function(Context):Promise<boolean>}
*/
doTryInitIpfs: () => async ({ store }) => {
// We need to swallow error that `doInitIpfs` could produce othrewise it
// will bubble up and nothing will handle it. There is a code in
// `bundles/retry-init.js` that reacts to `IPFS_INIT` action and attempts
// to retry.
// There is a code in `bundles/retry-init.js` that reacts to `IPFS_INIT`
// action and attempts to retry.
try {
await store.doInitIpfs()
return true
} catch (_) {
// Catches connection errors like timeouts
return false
}
},
/**
Expand Down Expand Up @@ -353,7 +397,7 @@ const actions = {
})

if (!result) {
throw Error('Could not connect to the IPFS API')
throw Error(`Could not connect to the IPFS API (${apiAddress})`)
} else {
return result
}
Expand All @@ -370,17 +414,46 @@ const actions = {

/**
* @param {string} address
* @returns {function(Context):Promise<void>}
* @returns {function(Context):Promise<boolean>}
*/
doUpdateIpfsApiAddress: (address) => async (context) => {
const apiAddress = asAPIOptions(address)
if (apiAddress == null) {
context.dispatch({ type: 'IPFS_API_ADDRESS_INVALID' })
context.dispatch({ type: ACTIONS.IPFS_API_ADDRESS_INVALID })
return false
} else {
await writeSetting('ipfsApi', apiAddress)
context.dispatch({ type: 'IPFS_API_ADDRESS_UPDATED', payload: apiAddress })

await context.store.doTryInitIpfs()
context.dispatch({ type: ACTIONS.IPFS_API_ADDRESS_UPDATED, payload: apiAddress })

// Sends action to indicate we're going to try to update the IPFS API address.
// There is logic to retry doTryInitIpfs in bundles/retry-init.js, so
// we're triggering the PENDING_FIRST_CONNECTION action here to avoid blocking
// the UI while we automatically retry.
context.dispatch({
type: ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION,
pending: true
})
context.dispatch({
type: ACTIONS.IPFS_STOPPED
})
context.dispatch({
type: ACTIONS.NOTIFY_DISMISSED
})
const succeeded = await context.store.doTryInitIpfs()
if (succeeded) {
context.dispatch({
type: ACTIONS.IPFS_CONNECT_SUCCEED,
})
} else {
context.dispatch({
type: ACTIONS.IPFS_CONNECT_FAILED,
})
}
context.dispatch({
type: ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION,
pending: false
})
return succeeded
}
},

Expand Down
34 changes: 34 additions & 0 deletions src/bundles/notify.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ const notify = {
}
}

if (action.type === 'IPFS_CONNECT_FAILED') {
return {
...state,
show: true,
error: true,
eventId: action.type
}
}
if (action.type === 'IPFS_CONNECT_SUCCEED') {
return {
...state,
show: true,
error: false,
eventId: action.type
}
}
if (action.type === 'IPFS_API_ADDRESS_INVALID') {
return {
...state,
show: true,
error: true,
eventId: action.type
}
}

return state
},

Expand All @@ -84,6 +109,15 @@ const notify = {
if (eventId === 'STATS_FETCH_FAILED') {
return provider === 'window.ipfs' ? 'windowIpfsRequestFailed' : 'ipfsApiRequestFailed'
}
if (eventId === 'IPFS_CONNECT_FAILED') {
return 'ipfsConnectFail'
}
if (eventId === 'IPFS_CONNECT_SUCCEED') {
return 'ipfsConnectSuccess'
}
if (eventId === 'IPFS_API_ADDRESS_INVALID') {
return 'ipfsInvalidApiAddress'
}

if (eventId === 'FILES_EVENT_FAILED') {
const type = code ? code.replace(/^(ERR_)/, '') : ''
Expand Down
25 changes: 21 additions & 4 deletions src/components/api-address-form/ApiAddressForm.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { connect } from 'redux-bundler-react'
import { withTranslation } from 'react-i18next'
import Button from '../button/Button'
import { checkValidAPIAddress } from '../../bundles/ipfs-provider'

const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress }) => {
const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFailed }) => {
const [value, setValue] = useState(asAPIString(ipfsApiAddress))
const initialIsValidApiAddress = !checkValidAPIAddress(value)
const [showFailState, setShowFailState] = useState(initialIsValidApiAddress || ipfsInitFailed)
const [isValidApiAddress, setIsValidApiAddress] = useState(initialIsValidApiAddress)

// Updates the border of the input to indicate validity
useEffect(() => {
setShowFailState(ipfsInitFailed)
}, [isValidApiAddress, ipfsInitFailed])

// Updates the border of the input to indicate validity
useEffect(() => {
const isValid = checkValidAPIAddress(value)
setIsValidApiAddress(isValid)
setShowFailState(!isValid)
}, [value])

const onChange = (event) => setValue(event.target.value)

Expand All @@ -25,13 +41,13 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress }) => {
id='api-address'
aria-label={t('apiAddressForm.apiLabel')}
type='text'
className='w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 focus-outline'
className={`w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 ${showFailState ? 'focus-outline-red b--red-muted' : 'focus-outline-green b--green-muted'}`}
onChange={onChange}
onKeyPress={onKeyPress}
value={value}
/>
<div className='tr'>
<Button className='tc'>{t('actions.submit')}</Button>
<Button className='tc' disabled={!isValidApiAddress}>{t('actions.submit')}</Button>
</div>
</form>
)
Expand All @@ -49,5 +65,6 @@ const asAPIString = (value) => {
export default connect(
'doUpdateIpfsApiAddress',
'selectIpfsApiAddress',
'selectIpfsInitFailed',
withTranslation('app')(ApiAddressForm)
)
18 changes: 16 additions & 2 deletions src/settings/SettingsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import Experiments from '../components/experiments/ExperimentsPanel'
import Title from './Title'
import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode'
import Checkbox from '../components/checkbox/Checkbox'
import ComponentLoader from '../loader/ComponentLoader.js'
import StrokeCode from '../icons/StrokeCode'
import { cliCmdKeys, cliCommandList } from '../bundles/files/consts'

const PAUSE_AFTER_SAVE_MS = 3000

export const SettingsPage = ({
t, tReady, isIpfsConnected,
t, tReady, isIpfsConnected, ipfsPendingFirstConnection,
isConfigBlocked, isLoading, isSaving,
hasSaveFailed, hasSaveSucceded, hasErrors, hasLocalChanges, hasExternalChanges,
config, onChange, onReset, onSave, editorKey, analyticsEnabled, doToggleAnalytics,
Expand All @@ -35,6 +36,17 @@ export const SettingsPage = ({
<Helmet>
<title>{t('title')} | IPFS</title>
</Helmet>

{/* Enable a full screen loader after updating to a new IPFS API address.
* Will not show on consequent retries after a failure.
*/}
{ ipfsPendingFirstConnection
? <div className="absolute flex items-center justify-center w-100 h-100"
style={{ background: 'rgba(255, 255, 255, 0.5)', zIndex: '10' }}>
<ComponentLoader pastDelay />
</div>
: null }


<Box className='mb3 pa4 joyride-settings-customapi'>
<div className='lh-copy charcoal'>
Expand Down Expand Up @@ -278,7 +290,7 @@ export class SettingsPageContainer extends React.Component {
const {
t, tReady, isConfigBlocked, ipfsConnected, configIsLoading, configLastError, configIsSaving,
configSaveLastSuccess, configSaveLastError, isIpfsDesktop, analyticsEnabled, doToggleAnalytics, toursEnabled,
handleJoyrideCallback, isCliTutorModeEnabled, doToggleCliTutorMode
handleJoyrideCallback, isCliTutorModeEnabled, doToggleCliTutorMode, ipfsPendingFirstConnection,
} = this.props
const { hasErrors, hasLocalChanges, hasExternalChanges, editableConfig, editorKey } = this.state
const hasSaveSucceded = this.isRecent(configSaveLastSuccess)
Expand All @@ -290,6 +302,7 @@ export class SettingsPageContainer extends React.Component {
t={t}
tReady={tReady}
isIpfsConnected={ipfsConnected}
ipfsPendingFirstConnection={ipfsPendingFirstConnection}
isConfigBlocked={isConfigBlocked}
isLoading={isLoading}
isSaving={configIsSaving}
Expand Down Expand Up @@ -321,6 +334,7 @@ export const TranslatedSettingsPage = withTranslation('settings')(SettingsPageCo
export default connect(
'selectConfig',
'selectIpfsConnected',
'selectIpfsPendingFirstConnection',
'selectIsConfigBlocked',
'selectConfigLastError',
'selectConfigIsLoading',
Expand Down

0 comments on commit e2e2edd

Please sign in to comment.