Created
July 28, 2019 13:04
-
-
Save wmertens/edb55463dbddc4c8164580fb0b63a3ee to your computer and use it in GitHub Desktop.
BoundRoute treats a URL like a React bound input
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This behaves like an input field with value and onChange, except that onChange can be called on mount | |
// Pass defaults so it knows which value keys to encode/decode | |
// Steps: | |
// Initial mount, or location changes: start at 1 | |
// Value changes: start at 4 | |
// 1. parse match into parsed | |
// 2. parse search, merge into parsed | |
// 3. post-process parsed => value; onChange(value) | |
// 4. pre-process value => toStore | |
// 5. create new pathname from toStore or keep current. This can mutate toStore | |
// 6. create new search from toStore or keep current | |
// 7. change URL accordingly | |
// | |
// Store the desired pathname/search from 7 so you know when location really changed | |
import React, {Component} from 'react' | |
import {Route, generatePath} from 'react-router' | |
import {isEqual} from 'lodash' | |
import {tryParse, stringify} from '@yaska-eu/jsurl2' | |
import PropTypes from 'prop-types' | |
// Babel 7 can't handle it as import | |
const {parse: qsParse, stringify: qsStringify} = require('query-string') | |
const isNotSubsetOf = (obj, other) => | |
Object.entries(obj).some(([key, val]) => !isEqual(other[key], val)) | |
export const _valueFromSearch = (defaults, search) => { | |
const keys = Object.keys(defaults) | |
const out = {} | |
const obj = qsParse(search) | |
for (const k of keys) { | |
const val = obj[k] | |
if (typeof val === 'undefined') { | |
// missing, default to current | |
out[k] = defaults[k] | |
} else if (val === null) { | |
// `?val&` - boolean true | |
out[k] = true | |
} else { | |
// If parsing failed, it was maybe a manual string from the user | |
out[k] = tryParse(val, obj[k]) | |
} | |
} | |
return out | |
} | |
export const _searchFromValue = (value, defaults, search = '') => { | |
const obj = qsParse(search) | |
for (const k of Object.keys(value)) { | |
const v = value[k] | |
const d = defaults[k] | |
if (d == null ? v != null : !isEqual(v, d)) { | |
// Differs from default | |
obj[k] = | |
v === true | |
? null // encode booleans as `?bool&` | |
: stringify(v, {short: true}) | |
} else { | |
// Matches default; remove | |
delete obj[k] | |
} | |
} | |
const out = qsStringify(obj, {strict: false}) | |
return out && `?${out}` | |
} | |
export const deriveValue = props => { | |
const {location, match, defaults, parseParams, parseQuery} = props | |
const parsed = match | |
? parseParams | |
? parseParams(match.params) || {} | |
: {...match.params} | |
: {} | |
const query = _valueFromSearch( | |
{...defaults, ...props.value, ...parsed}, | |
location.search | |
) | |
const value = (parseQuery && parseQuery(query)) || query | |
return value | |
} | |
export const deriveLocation = (props, value) => { | |
const {path, defaults, makeParams, makeQuery} = props | |
const location = {...props.location, key: undefined} | |
let toStore = value | |
if (path) { | |
// This can mutate toStore (to strip already-added params) | |
if (makeParams) toStore = {...toStore} | |
const params = | |
(makeParams && makeParams(toStore, props.match && props.match.params)) || | |
toStore | |
location.pathname = generatePath(path, params) | |
} | |
const query = (makeQuery && makeQuery(toStore)) || toStore | |
location.search = _searchFromValue(query, defaults, location.search) | |
return location | |
} | |
class EnsureLocation extends Component { | |
static propTypes = { | |
value: PropTypes.object.isRequired, | |
onChange: PropTypes.func.isRequired, | |
history: PropTypes.object.isRequired, | |
// These are used in deriving state | |
/* eslint-disable react/no-unused-prop-types */ | |
defaults: PropTypes.object.isRequired, | |
parseParams: PropTypes.func, | |
makeParams: PropTypes.func, | |
parseQuery: PropTypes.func, | |
makeQuery: PropTypes.func, | |
children: PropTypes.any, | |
location: PropTypes.object.isRequired, | |
/* eslint-enable react/no-unused-prop-types */ | |
} | |
// Slight hack: we use this lifecycle hook to get changes to props ASAP | |
static getDerivedStateFromProps(props, state) { | |
let nextState | |
if (props.value !== state.prevValue) nextState = {prevValue: props.value} | |
// We can be called even if props didn't change | |
let value = | |
(isEqual(props.value, state.prevValue) && state.value) || props.value | |
let {pathname: currentPath, search: currentSearch} = state | |
if (state.key !== props.location.key) { | |
// location really changed, see what the new value must be | |
if (!nextState) nextState = {} | |
nextState.key = props.location.key | |
currentPath = props.location.pathname | |
currentSearch = props.location.search | |
const derived = deriveValue(props) | |
if (derived && isNotSubsetOf(derived, value)) { | |
const {onChange} = props | |
value = derived | |
nextState.value = value | |
if (onChange) onChange(value) | |
} | |
} | |
// check what the location should be based on props and (new/old) value | |
const {pathname, search} = deriveLocation(props, value) | |
if (pathname !== currentPath || search !== currentSearch) { | |
// Our derived location is different | |
if (!nextState) nextState = {} | |
nextState.value = value | |
nextState.pathname = pathname | |
nextState.search = search | |
// TODO push/replace depending on loc change or not | |
props.history.replace({pathname, search}) | |
} | |
return nextState || null | |
} | |
state = {key: 'init', prevValue: this.props.value} | |
render() { | |
return this.props.children || false | |
} | |
} | |
const BoundRoute = props => { | |
const {location, path} = props | |
return ( | |
<Route {...{location, path}}> | |
{args => <EnsureLocation {...props} {...args} />} | |
</Route> | |
) | |
} | |
export default BoundRoute |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment