Repository URL to install this package:
|
Version:
7.0.2 ▾
|
import { computed, action, observable } from 'xmobx/mobx'
import qs from 'query-string'
import { matchPath } from 'react-router-dom'
import {
isObj,
isString,
isFunction,
EMPTY_ARRAY,
EMPTY_OBJ,
fromPairsToObj,
} from 'exotic'
import { stringify } from 'chain-able-lego'
import merge from 'chain-able-deps/dist/dopemerge'
// local
import makeHistory from './makeHistory'
import {
isRelativeWebAddress,
isFullyQualifiedWebAddress,
isStringifiedParams,
routePathCurrent,
goodLookingStringify,
parseObjWithSerializedValues,
} from './deps'
import { config } from './config'
import {
OneRouterSubscriberOnChange,
OneRouterOptions,
Location,
HistoryType,
RouterType,
ObjWithValuesSerialized,
OneUrl,
UpdateTo,
OneRouterToRuleThemAllRehydratable,
ParsedSearchParams,
MatchedValue,
Matches,
MatchPathParamsType,
OneRouterStoreType,
OneRouterEntriesType,
TapEntriesType,
AnyObj,
} from './typings'
/**
* @todo why is this not done in another file?
*/
export const history = makeHistory()
/**
* @todo @@perf dedupe from exotic
*/
const isNonEmptyString = (x: any): x is string => isString(x) && x !== ''
/**
* @todo @@perf freeze if it is safe
*/
export const EMPTY_ROUTER = {
history,
route: undefined,
location: undefined,
}
/**
* @todo subscribe, observe, unsubscribe
*
* @note modules/ does not hot reload
* extends Container
*/
class OneRouterToRuleThemAll<
EntriesType extends OneRouterEntriesType = OneRouterEntriesType
> {
// this.observersList = new WeakMap()
// observersList: Array<Function> = []
@observable.ref
oneUrl: OneUrl | undefined = undefined
// @todo @type OneRouterStoreType
// @todo can use this to avoid
// making all history and router observable
store = observable.map() as Map<string, any>
shallowStore = observable.map(undefined, { deep: false }) as Map<string, any>
// @todo put this in shallow store...
@observable.ref
_router: RouterType = EMPTY_ROUTER
/**
* @deprecated
*/
props: any
/**
* @deprecated remove this
* .shallow
*/
@observable
observable = {
urlList: [],
url: '',
}
@action
setAsDirty(isDirty: boolean = true) {
console.debug('marking as dirty: ' + isDirty)
// @todo could use `location` instead?
this.store.set('hasChangedSinceLastEntries', isDirty)
this.shallowStore.delete('entries')
return this
}
@computed
get url() {
// used ||, but @deprecated
return this.store.get('url') || this.observable.url || ''
}
get router() {
return this._router
}
set router(_router: RouterType) {
/**
* @todo remove this...
*/
const router = isObj(_router) ? _router : EMPTY_ROUTER
const historyToListenTo = isObj(router.history) ? router.history : history
if (this.shallowStore.has('listener')) {
const unsubscribe = this.shallowStore.get('listener')
unsubscribe()
}
if (isFunction(historyToListenTo.listen) === false) {
console.warn('oneRouter.router = {} < router is missing history.listen')
} else {
const listener = historyToListenTo.listen(this.handleRouteChange)
this.shallowStore.set('listener', listener)
}
this.setAsDirty(true)
this._router = router
}
handleRouteChange = (location: Location, actionName: string) => {
console.debug('[oneRouter] updated')
this.setAsDirty(true)
}
/**
* @todo maybe we should call this in .update
* @note this is being called by client, so the .goBackTo may be unsafe
*/
@action
setUrl(value: string) {
this.store.set('url', value)
if (this.store.has('urlList') === false) {
this.store.set('urlList', observable.array())
}
const urlList = this.store.get('urlList')
// ensure it is not empty + the last item in list is not the new value
const isUrlListEmpty = urlList.length === 0
const isUniqueUpdate =
!isUrlListEmpty && urlList[urlList.length - 1] !== value
if (isUrlListEmpty || isUniqueUpdate) {
urlList.push(value)
}
// @@remove - @@compatability
this.observable.url = value
this.observable.urlList = urlList
this.setAsDirty(true)
return this
}
@computed
get prevUrl() {
// @@perf could optimize by checking length
const length = this.observable.urlList.length
const lastIndex = length - 1
const last = this.observable.urlList[lastIndex]
return last || ''
}
@action
setTapEntries(tapEntries?: TapEntriesType) {
if (tapEntries === undefined) {
this.store.delete('tapEntries')
} else {
this.store.set('tapEntries', tapEntries)
}
this.setAsDirty(true)
return this
}
// @computed
get location() {
// @note changed in new react-router
// if (process.env.NODE_ENV === 'development') {
// if ((this.router as any).location) {
// throw new Error('invalid router - has location')
// }
// }
return this.history.location
}
/**
* @todo @@typings
*/
get(key: string) {
const entries = this.entries()
return entries[key] === undefined ? this.store.get(key) : entries[key]
}
@action
set(key: string, value: any): this {
this.store.set(key, value)
return this
}
/**
* @todo @@typings
*/
@action
from(obj) {
const keys = Object.keys(obj)
let didChange = false
// const original = stringify(this.entries())
// const changed = stringify(obj)
// if (original === changed) {
// return
// }
// // go through keys
keys.forEach(key => {
const value = obj[key]
// update if it changed
if (this.store.get(key) !== value) {
this.store.set(key, value)
didChange = true
}
})
// // could also set the .diff
// if (didChange === true) {
// this.observersList.forEach(subscriber => subscriber(this))
// }
return this
}
get urlFromExpress(): OneUrl {
// @note this is because of the next line with the `set`
return (this.location as any).full || ''
}
/**
* @todo action @@strict
*/
set urlFromExpress(oneUrl: OneUrl) {
this.oneUrl = oneUrl
this.history.location = oneUrl as any
}
/**
* @todo optimize with computed
*/
toString(): string {
const entries = this.entries()
return stringify(entries as any)
}
/**
* @todo can use this for debug
* replacing using `pretty: true` in toString
* @todo can serialize ALL like the `store` etc
*/
toJSON() {
// oneUrl, store,
// router, router.history, router.route,
// props, observable
// toString()
return {
entries: this.entries(),
}
}
/**
* @note the type was `AnyObj | string`
* but not sure how that makes much sense
* don't see the use case, YAGNI
*/
has(data: string): boolean {
return this.toString().includes(data)
}
/**
* @see history/goBack
* @description common naming
* @alias goBack
* @alias gotoBack
*/
gotoPrev() {
/**
* @todo - should unify this history usage stuff & simplify v3
*/
this.history.goBack()
}
gotoNext() {
this.history.goForward()
}
/**
* @example oneRouter.goBackTo(-2)
*/
goBackTo(index: number) {
// last index + current index
const lastIndex = this.observable.urlList.length - 1
const urlIndex = lastIndex + index
const originUrl = this.entries().origin
if (process.env.NODE_ENV !== 'production') {
console.debug('[1router] goBackTo: ' + urlIndex)
if (this.observable.urlList[urlIndex] === undefined) {
console.warn('[1router] trying to access invalid index: ' + urlIndex)
console.log(this.observable.urlList)
return
}
}
/**
* @todo clean this up
*/
const urlToUpdate = this.observable.urlList[urlIndex]
.split('}')
.pop()
.replace(originUrl, '')
this.setUrl(urlToUpdate).update(urlToUpdate)
}
/**
* @example https://uxui.com/route?search=eh#hash
* @alias fullyQualifiedWebAddress
* @alias absolute
* @alias absoluteWebAddress
*/
public get full(): string {
const IS_BROWSER = typeof window === 'object'
return IS_BROWSER
? window.location.href
: this.history.location.href ||
this.history.location.origin + '/' + this.history.location.pathname
}
/**
* @note also available in entries
* and the history.location can be from express
*/
get origin(): string {
const IS_BROWSER = typeof window === 'object'
return IS_BROWSER
? window.location.origin
: this.history.location.origin || this.history.location.origin
}
/**
* @description (pathname.match(/\w+/gim) || []).join('')
*/
// @computed
public get pathname(): string {
const IS_BROWSER = typeof window === 'object'
if (IS_BROWSER) {
console.debug('[1router] window.location.pathname')
return window.location.pathname
}
if (this.oneUrl !== undefined) {
console.debug('[1router] oneUrl')
return this.oneUrl.pathname
}
if (typeof global === 'object' && (global as any).oneUrl !== undefined) {
console.debug('[1router] global.oneUrl.pathname')
return (global as any).oneUrl.pathname
}
if (isNonEmptyString(this.urlFromExpress)) {
console.debug('[1router] urlFromExpress')
return String(this.urlFromExpress)
}
if (
isObj(this.router) &&
isObj(this.history) &&
isObj(this.history.location) &&
isString(this.history.location.pathname) &&
this.history.location.pathname !== '/'
) {
console.debug('[1router] router.history.location.pathname 1')
return this.history.location.pathname
}
if (process.env.NODE_ENV !== 'production') {
if ((this.history as any).pathname) {
throw new Error('history has pathname')
}
}
if (this.history.location.pathname) {
console.debug('[1router] history.location.pathname 2')
return this.history.location.pathname
}
const unknown = new Error('could not find pathname')
console.error(unknown)
console.log(this)
return '@@unknown'
}
/**
* @example
* localhost/eh/10/
* /eh/:categoryId
* match.params.categoryId
* => 10
*/
@computed
private get matched(): Matches {
const matched = {}
const pathname = this.pathname
const routePathsList = config.get('routePathsList')
// @todo forEach for readability?
//
// go through our routes
// use the sealed object for perf
// match params (which has a cache)
// merge with matched object
// return matched
for (let index = 0; index < routePathsList.length; index++) {
const matchablePath = routePathsList[index]
routePathCurrent.path = matchablePath
const matchedFromPath = matchPath(
pathname,
routePathCurrent
) as MatchPathParamsType
// !!!!!!!!! IMPORTANT
// console.log(
// '[1router] ____MATCHED___',
// index,
// pathname,
// matchedFromPath,
// matchablePath
// )
if (isObj(matchedFromPath)) {
// @note this was
// const params = { ...matchedFromPath.params }
Object.assign(matched, matchedFromPath.params)
}
}
return matched
}
/**
* @todo not sure if it's used
*/
public get searchParams(): string | ParsedSearchParams {
return this.getSearchParams()
}
/**
* @note - removed `@computed` from it
* since we had issues with that updating before
* because `router` is a ref, not an observable
* @description this.router this.history history
*/
// @computed
public get history(): HistoryType {
return isObj(this.router) && isObj(this.router.history)
? this.router.history
: history
}
/**
* @see http://unixpapa.com/js/querystring.html
* @api https://github.com/sindresorhus/query-string#nesting
* @todo these entries could be observable...
*/
getSearchParams<ReturnType = ParsedSearchParams>(
fallback: string | any = EMPTY_OBJ
): ParsedSearchParams | ReturnType {
const IS_BROWSER = typeof window === 'object'
/**
* @todo use URLSearchParams not qs...
*/
const parsed = this.history.location.search
? qs.parse(this.history.location.search)
: fallback
if (!parsed) {
return parsed
}
// could optimize this
const searchParams = {}
Object.assign(searchParams, parsed)
/**
* it does not support doing things like
* @example &eh=1&eh=10
*/
if (IS_BROWSER) {
const params = new URLSearchParams(window.location.search)
const entries = Array.from(params as any)
const obj = fromPairsToObj(entries)
Object.assign(searchParams, obj)
}
const final = this.parse(searchParams)
return final
}
/**
* @todo should add parsing options
* @param parsed object with values that can be parsed
* @return destringified/parsed
*/
parse(parsed: ObjWithValuesSerialized): ObjWithValuesSerialized {
if (isObj(parsed)) {
parseObjWithSerializedValues(parsed)
}
return parsed
}
/**
* @todo !!! optimize with a hash for sure
*
* @todo computed
* @todo this needs to get the routing from routing
* ^ need to put in config
*
* @alias toObj
*/
entries(): EntriesType {
if (this.store.get('hasChangedSinceLastEntries')) {
this.setAsDirty(false)
} else if (this.shallowStore.has('entries')) {
console.debug('[1router] using optimized .entries')
return this.shallowStore.get('entries')
} else {
console.warn('[1router] should never get here...')
}
const IS_BROWSER = typeof window === 'object'
const pathname = this.pathname
const browserLocation = IS_BROWSER ? window.location : EMPTY_OBJ
const params = this.getSearchParams()
const matched = this.matched
/**
* @todo should make things not enumerable here
*/
const flattenedActions = {
...browserLocation,
...this.history,
...this.history.location,
...this.router,
}
// autofixSafe
const entries = {
pathname,
...params,
...matched,
}
const mergedEntries = {
...flattenedActions,
...entries,
}
console.debug('[1router] calling .entries')
if (this.store.has('tapEntries') === true) {
const tapEntries = this.store.get('tapEntries')
const finalEntries = tapEntries(mergedEntries)
this.shallowStore.set('entries', finalEntries)
} else {
this.shallowStore.set('entries', mergedEntries)
}
return this.shallowStore.get('entries') as any
}
/**
* @tutorial https://github.com/ReactTraining/history/blob/master/modules/LocationUtils.js
* @alias push
* @alias merge
* @alias updateQueryParams
*
* @fires observable update
* @description any kind of url update can be done here
*
* @param to
*
* @example
* [state, pathname, hash]
* toUrlOrPathNameOrParams, optionalParamAsDataOrPath
* let to = toUrlOrPathNameOrParams
* let state = optionalParamAsDataOrPath
* let hash
* if (arguments.length === 2)
*/
@action
update(
to: UpdateTo,
options: OneRouterOptions = {
shouldStringify: true,
shouldMerge: true,
shouldUseNative: false,
}
) {
console.info('oneRouter.update(to =' + JSON.stringify(to) + ')')
this.setAsDirty(true)
if (isObj(to)) {
console.info('oneRouter.update isObj(to)')
// if (to.search) {
// to = to.search
// }
// if (to.hash) {
// return this.update('#' + to.hash)
// }
if (process.env.NODE_ENV !== 'production') {
if ((to as any).pathname) {
console.warn(`avoid stringifying history-like properties: ` + to)
}
}
// Commented the below line for filter to work in plp
const searchParams = {}
// const searchParams = this.getSearchParams(false)
console.info('.update(to = ', to, ')')
if (options.shouldMerge !== false) {
to = merge(searchParams, to as any)
console.info('oneRouter.update(to =' + JSON.stringify(to) + ')')
}
// const params = options.shouldStringify ? qs.stringify(to) : to
const params = goodLookingStringify(to)
if (options.shouldUseNative === true) {
// to, document.title, url
window.history.pushState(to, '', this.pathname + '?' + params)
return this
} else {
this.history.push({ pathname: this.pathname, search: params })
}
}
// @todo types below should work
// not a string
if (!isString(to)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
'oneRouter.update(to, opts) must have an object or a string as `to`'
)
}
}
// @note this works but we don't want to use this
// the way it was being used in our domain/reference-store
// else if (isHash(to)) {
// this.history.push({ pathname: this.pathname, hash: to })
// return this
// }
else if (isStringifiedParams(to)) {
console.info('isStringifiedParams')
const parsed = qs.parse(to)
return this.update(parsed)
}
//
// @todo for the below two cases, check our local routes
//
// @example https://eh.com
else if (isFullyQualifiedWebAddress(to)) {
console.info('isFullyQualifiedWebAddress')
this.history.push({
pathname: to,
})
}
// @example /eh
else if (isRelativeWebAddress(to)) {
console.info('isRelativeWebAddress')
this.history.replace(to)
}
// uglifyable
else {
return this
}
}
/**
* @todo !!! should be used as last resort, need to debug usage
*/
forceUpdate(path: string): this {
console.warn(
'[1router] should not be using .forceUpdate, this is a last resort!'
)
this.setAsDirty(true)
const finalPath = isRelativeWebAddress(path) ? this.origin + path : path
const IS_BROWSER = typeof window === 'object'
if (IS_BROWSER) {
window.location.href = path
console.warn('todo - for tests')
// window.location.pathname = path
}
this.history.location.href = finalPath
this.store.clear()
return this
}
/**
* @param param query param to delete
*/
@action
delete(param: string): this {
this.store.delete(param)
this.setAsDirty(true)
if (isString(param)) {
const searchParamsRaw = this.getSearchParams()
if (searchParamsRaw === undefined) {
return this
}
/**
* @todo there be a better way to do this...
*/
const searchParams = this.parse(
searchParamsRaw as ObjWithValuesSerialized
)
delete searchParams[param]
return this.update(searchParams)
} else {
return this
}
}
/**
* @alias goto
* @description same as update, but goto this url instead of merging
*/
@action
replace(path: string): this {
this.setAsDirty(true)
this.history.replace(path)
return this
}
/**
* @description reset url, goto root
*/
@action
clearHistory(): this {
this.setAsDirty(true)
this.history.replace({
pathname: this.location.pathname,
search: ``,
})
return this
}
clear(): this {
this.setAsDirty(true)
this.history.location.pathname = ''
this.history.location.href = ''
this.history.location.origin = ''
this.clearHistory()
this.store.clear()
return this
}
/**
* @alias observe
* @alias subscribe
* @param subscriber called when observable changes
*/
onChange(subscriber: OneRouterSubscriberOnChange): this {
const subscribeToRouteChange = this.history.listen
if (isFunction(subscribeToRouteChange)) {
subscribeToRouteChange(subscriber)
}
if (process.env.NODE_ENV !== 'production') {
console.assert(
typeof subscribeToRouteChange === 'function',
'ON_CHANGE_ONE_ROUTER_NOT_FUNCTION'
)
}
return this
}
/**
* @description window.location.reload()
*/
reload(): void {
window.location.reload()
}
/**
* @todo use chain-able/matcher
* ^ regexp, function, or string, or string wildcard/route-params
* @example isMatch(restrictedRoutes, this.pathname)
*/
@computed
get isRestricted(): boolean {
const restrictedRoutes = this.get('restrictedRoutes') || EMPTY_ARRAY
const isRestricted = restrictedRoutes.includes(this.pathname)
return isRestricted
}
}
if (process.env.NODE_ENV !== 'production') {
/**
* @todo @@prod
*/
OneRouterToRuleThemAll.prototype.version = '7.0.2'
}
export { OneRouterToRuleThemAll }
export default OneRouterToRuleThemAll