Skip to content

Commit

Permalink
feat: config page ux improvements (#315)
Browse files Browse the repository at this point in the history
* feat: convert config inputs to textarea
* fix: save config works
* feat: add descriptions to config page inputs
* chore: package-lock.json update
* refactor(configuration): css tweaks
* fix: localStorage stores stringified json

---------

Co-authored-by: Marcin Rataj <lidel@lidel.org>
  • Loading branch information
SgtPooki and lidel authored Jul 4, 2024
1 parent 774e388 commit 16b31b9
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 40 deletions.
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 37 additions & 16 deletions src/components/local-storage-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,38 @@ export interface LocalStorageInputProps extends React.DetailedHTMLProps<React.HT
defaultValue: string
validationFn?(value: string): Error | null
resetKey: number
description?: string
preSaveFormat?(value: string): string
postLoadFormat?(value: string): string
}

const defaultValidationFunction = (value: string): Error | null => {
try {
JSON.parse(value)
value.split('\n')
return null
} catch (err) {
return err as Error
}
}
export default ({ resetKey, localStorageKey, label, placeholder, validationFn, defaultValue, ...props }: LocalStorageInputProps): JSX.Element => {
const [value, setValue] = useState(localStorage.getItem(localStorageKey) ?? defaultValue)

const getFromLocalStorage = (postLoadFormat?: (arg0: string) => string) => (key: string, fallback: string) => {
let localStorageValue = localStorage.getItem(key)
if (localStorageValue != null && postLoadFormat != null) {
localStorageValue = postLoadFormat(localStorageValue)
}
return localStorageValue ?? fallback
}

/**
* A Local storage input (text area) component that saves the input to local storage.
*/
export default ({ resetKey, localStorageKey, label, placeholder, validationFn, defaultValue, description, preSaveFormat, postLoadFormat, ...props }: LocalStorageInputProps): JSX.Element => {
const localStorageLoadFn = getFromLocalStorage(postLoadFormat)
const [value, setValue] = useState(localStorageLoadFn(localStorageKey, defaultValue))
const [error, setError] = useState<null | Error>(null)

useEffect(() => {
setValue(localStorage.getItem(localStorageKey) ?? defaultValue)
setValue(localStorageLoadFn(localStorageKey, defaultValue))
}, [resetKey])

if (validationFn == null) {
Expand All @@ -35,26 +51,31 @@ export default ({ resetKey, localStorageKey, label, placeholder, validationFn, d
if (err != null) {
throw err
}
localStorage.setItem(localStorageKey, value)
localStorage.setItem(localStorageKey, preSaveFormat?.(value) ?? value)
setError(null)
} catch (err) {
setError(err as Error)
}
}, [value])

props = {
...props,
className: `${props.className ?? ''} flex-column items-start mb3`
}

return (
<div {...props}>
<label htmlFor={localStorageKey} className='f5 ma0 pb2 aqua fw4 db'>{label}:</label>
<input
className='input-reset bn black-80 bg-white pa3 w-100 mb3'
id={localStorageKey}
name={localStorageKey}
type='text'
placeholder={placeholder}
value={value}
onChange={(e) => { setValue(e.target.value) }}
/>
{error != null && <span style={{ color: 'red' }}>{error.message}</span>}
<label htmlFor={localStorageKey} className='f5 ma0 pt3 teal fw4 db'>{label}</label>
<span className="charcoal-muted f6 fw1 db pt1 lh-copy">{description}</span>
<textarea
className='input-reset ba br2 b--light-silver code lh-copy black-80 bg-white pa2 w-100 mt2'
id={localStorageKey}
name={localStorageKey}
placeholder={placeholder}
value={value}
onChange={(e) => { setValue(e.target.value) }}
/>
{error != null && <span className='db lh-copy red pt1 tr f6 w-100'>{error.message}</span>}
</div>
)
}
2 changes: 1 addition & 1 deletion src/components/local-storage-toggle.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/* width: 100%; */
/* height: 100%; */
position: relative;
background-color: grey;
background-color: #b7bbc8;
color: white;
transition: all 0.5s ease;
padding: 3px;
Expand Down
8 changes: 4 additions & 4 deletions src/lib/config-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface ConfigDb extends BaseDbConfig {

export const defaultGateways = ['https://trustless-gateway.link']
export const defaultRouters = ['https://delegated-ipfs.dev']
export const defaultDnsJsonResolvers = {
export const defaultDnsJsonResolvers: Record<string, string> = {
'.': 'https://delegated-ipfs.dev/dns-query'
}

Expand Down Expand Up @@ -73,9 +73,9 @@ export async function setConfig (config: ConfigDb, logger: ComponentLogger): Pro

export async function getConfig (logger: ComponentLogger): Promise<ConfigDb> {
const log = logger.forComponent('get-config')
let gateways: string[] = defaultGateways
let routers: string[] = defaultRouters
let dnsJsonResolvers: Record<string, string> = defaultDnsJsonResolvers
let gateways = defaultGateways
let routers = defaultRouters
let dnsJsonResolvers = defaultDnsJsonResolvers
let autoReload = false
let debug = ''

Expand Down
19 changes: 19 additions & 0 deletions src/lib/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,22 @@ export const LOCAL_STORAGE_KEYS = {
requestPath: getLocalStorageKey('forms', 'requestPath')
}
}

export const convertUrlArrayToInput = (urls: string[]): string => {
return urls.join('\n')
}

export const convertUrlInputToArray = (newlineDelimitedString: string): string[] => {
return newlineDelimitedString.split('\n').map((u) => u.trim())
}

export const convertDnsResolverObjectToInput = (dnsResolvers: Record<string, string>): string => {
return Object.entries(dnsResolvers).map(([key, url]) => `${key} ${url}`).join('\n')
}

export const convertDnsResolverInputToObject = (dnsResolverInput: string): Record<string, string> => {
return dnsResolverInput.split('\n').map((u) => u.trim().split(' ')).reduce((acc, [key, url]) => {
acc[key] = url
return acc
}, {})
}
88 changes: 78 additions & 10 deletions src/pages/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ import { RouteContext } from '../context/router-context.jsx'
import { ServiceWorkerProvider } from '../context/service-worker-context.jsx'
import { HeliaServiceWorkerCommsChannel } from '../lib/channel.js'
import { defaultDnsJsonResolvers, defaultGateways, defaultRouters, getConfig, loadConfigFromLocalStorage, resetConfig } from '../lib/config-db.js'
import { LOCAL_STORAGE_KEYS } from '../lib/local-storage.js'
import { LOCAL_STORAGE_KEYS, convertDnsResolverInputToObject, convertDnsResolverObjectToInput, convertUrlArrayToInput, convertUrlInputToArray } from '../lib/local-storage.js'
import { getUiComponentLogger, uiLogger } from '../lib/logger.js'
import './default-page-styles.css'

const uiComponentLogger = getUiComponentLogger('config-page')
const log = uiLogger.forComponent('config-page')
const channel = new HeliaServiceWorkerCommsChannel('WINDOW', uiComponentLogger)

/**
* Converts newline delimited URLs to an array of URLs, and validates each URL.
*/
const urlValidationFn = (value: string): Error | null => {
try {
const urls = JSON.parse(value) satisfies string[]
const urls: string[] = convertUrlInputToArray(value)
let i = 0
if (urls.length === 0) {
throw new Error('At least one URL is required. Reset the config to use defaults.')
Expand All @@ -29,17 +32,27 @@ const urlValidationFn = (value: string): Error | null => {
return new URL(url)
})
} catch (e) {
throw new Error(`URL "${urls[i]}" at index ${i} is not valid`)
throw new Error(`URL "${urls[i]}" on line ${i} is not valid`)
}
return null
} catch (err) {
return err as Error
}
}

/**
* Converts newline delimited patterns of space delimited key+value pairs to a JSON object, and validates each URL.
*
* @example
* ```
* . https://delegated-ipfs.dev/dns-query
* .com https://cloudflare-dns.com/dns-query
* .eth https://eth.link/dns-query
* ```
*/
const dnsJsonValidationFn = (value: string): Error | null => {
try {
const urls: Record<string, string> = JSON.parse(value)
const urls: Record<string, string> = convertDnsResolverInputToObject(value)
let i = 0
if (Object.keys(urls).length === 0) {
throw new Error('At least one URL is required. Reset the config to use defaults.')
Expand All @@ -50,7 +63,10 @@ const dnsJsonValidationFn = (value: string): Error | null => {
return new URL(url)
})
} catch (e) {
throw new Error(`URL "${urls[i]}" at index ${i} is not valid`)
if (urls[i] != null) {
throw new Error(`URL "${urls[i]}" at index ${i} is not valid`)
}
throw new Error(`Input on line ${i} with key "${Object.keys(urls)[i]}" is not valid`)
}
return null
} catch (err) {
Expand Down Expand Up @@ -108,6 +124,7 @@ function ConfigPage (): React.JSX.Element | null {
gotoPage()
}
} catch (err) {
log.error('Error saving config', err)
setError(err as Error)
}
}, [])
Expand All @@ -119,14 +136,65 @@ function ConfigPage (): React.JSX.Element | null {
setResetKey((prev) => prev + 1)
}, [])

const newlineStringSave = (value: string): string => JSON.stringify(convertUrlInputToArray(value))
const newlineStringLoad = (value: string): string => convertUrlArrayToInput(JSON.parse(value))

return (
<main className='e2e-config-page pa4-l bg-snow mw7 center pa4'>
<Collapsible collapsedLabel="View config" expandedLabel='Hide config' collapsed={isLoadedInIframe}>
<LocalStorageInput className="e2e-config-page-input e2e-config-page-input-gateways" localStorageKey={LOCAL_STORAGE_KEYS.config.gateways} label='Gateways' validationFn={urlValidationFn} defaultValue={JSON.stringify(defaultGateways)} resetKey={resetKey} />
<LocalStorageInput className="e2e-config-page-input e2e-config-page-input-routers" localStorageKey={LOCAL_STORAGE_KEYS.config.routers} label='Routers' validationFn={urlValidationFn} defaultValue={JSON.stringify(defaultRouters)} resetKey={resetKey} />
<LocalStorageInput className="e2e-config-page-input e2e-config-page-input-dnsJsonResolvers" localStorageKey={LOCAL_STORAGE_KEYS.config.dnsJsonResolvers} label='DNS (application/dns-json) resolvers' validationFn={dnsJsonValidationFn} defaultValue={JSON.stringify(defaultDnsJsonResolvers)} resetKey={resetKey} />
<LocalStorageToggle className="e2e-config-page-input e2e-config-page-input-autoreload" localStorageKey={LOCAL_STORAGE_KEYS.config.autoReload} onLabel='Auto Reload' offLabel='Show Config' resetKey={resetKey} />
<LocalStorageInput className="e2e-config-page-input" localStorageKey={LOCAL_STORAGE_KEYS.config.debug} label='Debug logging' validationFn={stringValidationFn} defaultValue='' resetKey={resetKey} />
<LocalStorageInput
className="e2e-config-page-input e2e-config-page-input-gateways"
description="A newline delimited list of trustless gateway URLs."
localStorageKey={LOCAL_STORAGE_KEYS.config.gateways}
label='Gateways'
validationFn={urlValidationFn}
defaultValue={convertUrlArrayToInput(defaultGateways)}
preSaveFormat={newlineStringSave}
postLoadFormat={newlineStringLoad}
resetKey={resetKey}
/>
<LocalStorageInput
className="e2e-config-page-input e2e-config-page-input-routers"
description="A newline delimited list of delegated IPFS router URLs."
localStorageKey={LOCAL_STORAGE_KEYS.config.routers}
label='Routers'
validationFn={urlValidationFn}
defaultValue={convertUrlArrayToInput(defaultRouters)}
preSaveFormat={newlineStringSave}
postLoadFormat={newlineStringLoad}
resetKey={resetKey}
/>
<LocalStorageInput
className="e2e-config-page-input e2e-config-page-input-dnsJsonResolvers"
description="A newline delimited list of space delimited key+value pairs for DNS (application/dns-json) resolvers. The key is the domain suffix, and the value is the URL of the DNS resolver."
localStorageKey={LOCAL_STORAGE_KEYS.config.dnsJsonResolvers}
label='DNS'
validationFn={dnsJsonValidationFn}
defaultValue={convertDnsResolverObjectToInput(defaultDnsJsonResolvers)}
preSaveFormat={(value) => JSON.stringify(convertDnsResolverInputToObject(value))}
postLoadFormat={(value) => convertDnsResolverObjectToInput(JSON.parse(value))}
resetKey={resetKey}
/>

<div className='f5 ma0 pt3 teal fw4 db'>Interstitials</div>
<span className="charcoal-muted f6 fw1 db pt1 pb1 lh-copy">Control if visiting a new origin should display interstitial pages or automatically load the content using existing configuration.</span>
<LocalStorageToggle
className="e2e-config-page-input e2e-config-page-input-autoreload"
localStorageKey={LOCAL_STORAGE_KEYS.config.autoReload}
onLabel='Auto Reload'
offLabel='Show Config'
resetKey={resetKey}
/>

<LocalStorageInput
className="e2e-config-page-input"
description="A string that enables debug logging. Use '*,*:trace' to enable all debug logging."
localStorageKey={LOCAL_STORAGE_KEYS.config.debug}
label='Debug'
validationFn={stringValidationFn}
defaultValue=''
resetKey={resetKey}
/>
<div className="w-100 inline-flex flex-row justify-between">
<button className="e2e-config-page-button button-reset mr5 pv3 tc bg-animate hover-bg-gold pointer w-30 bn" id="reset-config" onClick={() => { void doResetConfig() }}>Reset Config</button>
<ServiceWorkerReadyButton className="e2e-config-page-button white w-100 pa3" id="save-config" label='Save Config' waitingLabel='Waiting for service worker registration...' onClick={() => { void saveConfig() }} />
Expand Down

0 comments on commit 16b31b9

Please sign in to comment.