Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add visual feedback on API address change #1671

Merged
merged 28 commits into from
Oct 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
23d5f3f
Add error text to API address settings
jack-michaud Oct 14, 2020
7d56569
Populate error message for invalid addresses and connection errors
jack-michaud Oct 15, 2020
6268c09
Add outline to ApiAddressForm to indicate address validity
jack-michaud Oct 15, 2020
ff59fd5
Add apiAddress to "could not connect" message
jack-michaud Oct 15, 2020
d4a4456
Add "pending first API connection" handlers to ipfs-provider
jack-michaud Oct 15, 2020
4a89afd
Add full page loader when pending first connection
jack-michaud Oct 15, 2020
01d3cf2
Remove custom error CSS, instead use Notify for errors
jack-michaud Oct 18, 2020
7483a73
Feedback from @rafaelramalho19 - Use arrow function
jack-michaud Oct 18, 2020
9c46977
Feedback from @jessicashilling and @rafaelramalho19
jack-michaud Oct 18, 2020
1cddd15
Remove connectionError action in ipfs-provider.
jack-michaud Oct 18, 2020
58b0f3c
Remove unused icon, comment
jack-michaud Oct 18, 2020
ebd2af5
Fix bug that shows success message before updating API address
jack-michaud Oct 18, 2020
d88fb5a
Fix formatting
jack-michaud Oct 18, 2020
80f86f9
Remove unused "dispatch"
jack-michaud Oct 18, 2020
e3dbde0
Add custom error messages for connecting to a new IPFS API
jack-michaud Oct 18, 2020
2cf8a47
IPFS_CONNECT_SUCCEED/FAILED set fail state in ipfs-provider
jack-michaud Oct 19, 2020
da320ed
Return result of API address update in doUpdateIpfsApiAddress
jack-michaud Oct 19, 2020
4bf7436
Add ipfsInvalidApiAddress to locales/en/notify.json and notify.js
jack-michaud Oct 19, 2020
c330e50
Refocus on input if the API address failed to update
jack-michaud Oct 19, 2020
10d2fb0
Show green/red border for valid/invalid API address or red border for…
jack-michaud Oct 19, 2020
2746ae8
Change ApiAddressForm to more closely follow SelectPeer
jack-michaud Oct 19, 2020
31d53de
Clean up unused code and comments
jack-michaud Oct 19, 2020
038cbc8
Update comments
jack-michaud Oct 19, 2020
34a1c0a
Remove useRef from imports
jack-michaud Oct 19, 2020
e3a41ad
Disables button with an invalid multiaddr and display red border when
jack-michaud Oct 20, 2020
daef34f
Follow formatting
jack-michaud Oct 20, 2020
4954294
Merge branch 'master' of github.com:ipfs-shipyard/ipfs-webui into fea…
jack-michaud Oct 28, 2020
67a4173
Feedback from @rafaelramalho19
jack-michaud Oct 28, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
}
jack-michaud marked this conversation as resolved.
Show resolved Hide resolved

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