Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
@skava/router / src / OneRouterToRuleThemAll.ts
Size: Mime:
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