Skip to content

Instantly share code, notes, and snippets.

@wmertens
Created July 28, 2019 13:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wmertens/edb55463dbddc4c8164580fb0b63a3ee to your computer and use it in GitHub Desktop.
Save wmertens/edb55463dbddc4c8164580fb0b63a3ee to your computer and use it in GitHub Desktop.
BoundRoute treats a URL like a React bound input
// 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