Source: navigation-component.js

import React from 'react'
import { EnNavigationApi } from 'ernnavigation-api'
import AppNavigator from './app-navigator'

/**
 * @class Component
 * @description
 * <u><b>NOTE</b></u>:<br>
 * If overriding <code>componentWillUnmount</code> or <code>componentWillUpdate</code>, you <u><b>must</b></u> call the
 * appropriate super method - <code>super.componentWillUnmount()</code> or
 * <code>super.componentWillUpdate(nextProps, nextState)</code>, respectively.
 * @extends React.Component
 * @example
 * import { Component } from 'ern-navigation'
 * ...
 * export default MainScreenComponent extends Component {
 *   static displayName = 'Main Screen'
 *   static navigationOptions = {
 *     title: 'My Application',
 *     buttons: [{
 *       icon: Image.resolveAssetSource(exitIcon).uri,
 *       id: 'exit',
 *       location: 'right',
 *       accessibilityLabel: 'Exit this app'
 *     }]
 *   }
 *  onNavButtonPress (buttonId) {
 *    switch (buttonId) {
 *      case 'exit':
 *        this.finish()
 *        break
 *      default:
 *        console.warn(`Screen '${MainScreenComponent.getRegisteredRoute()}' received unmapped button id '${buttonId}'`)
 *        break
 *    }
 *  }
 *   ...
 * }
 */

/**
 * @typedef {Object} NavigationBar
 * @property {string} title -The title for the navigation bar.
 * @property {Button[]} buttons - The {@link Button}s to display on the navigation bar.
 */

/**
 * @typedef {Object} Button
 * @property {?string} icon - The location of the icon (use <code>Image.resolveAssetSource(iconFile).uri</code>)
 * or the name of a built-in icon.
 * @property {!string} id - The ID of the button; will be used in header button events.  Cannot contain '.'.
 * @property {('left'|'right')} [location='right'] - (optional) Where to display the icon (either 'left' or 'right').
 * @property {?string} accessibilityLabel - The text to read out with screen-reader technology.
 */

export const errors = {
  invalidAppNavigator: new Error('The appNavigator has not been set or is not a valid instance of AppNavigator.  Internal navigation is not available.'),
  noValidScreens: new Error('No valid screens have been set.  Internal navigation is not available.'),
  noScreenName: new Error('The screenName is required'),
  invalidScreenName: (screenName) => new Error(`'${screenName}' is not a valid screen name.`)
}

class Component extends React.Component {
  static appNavigator = undefined
  static headerListener = undefined
  static displayName = 'Component'
  static route = ''
  static navigationOptions = {
    title: 'Untitled',
    buttons: []
  }

  constructor (props) {
    super(props)
    this.jsonProps = props.jsonPayload ? JSON.parse(props.jsonPayload) : {}
    if (!this.headerListener) {
      this.headerListener = EnNavigationApi.events().addOnNavButtonClickEventListener(this.constructor._handleNavButtonPress.bind(this))
    }
  }

  componentWillUnmount () {
    if (this.headerListener) {
      EnNavigationApi.events().removeOnNavButtonClickEventListener(this.headerListener)
    }
  }

  componentWillUpdate (nextProps) {
    if (this.props.jsonPayload !== nextProps.jsonPayload) {
      this.jsonProps = nextProps.jsonPayload ? JSON.parse(nextProps.jsonPayload) : {}
    }
  }

  /**
   * Set the registered route for this component.
   *
   * @static
   * @param {string} route - The registered route for this component.
   */
  static setRegisteredRoute (route) {
    if (!this.route) {
      this.route = route
    } else {
      console.warn(`This component has already been registered as '${this.route}'.  Not re-registering as '${route}'.`)
    }
  }

  /**
   * Get the registered route for this component.
   *
   * @static
   * @returns {string} A string containing the registered route for this component.
   */
  static getRegisteredRoute () {
    return this.route
  }

  /**
   * Set the {@link AppNavigator} for this component.
   *
   * @param {AppNavigator} appNavigator - The {@link AppNavigator} for this component.
   */
  static setAppNavigator (appNavigator) {
    this.appNavigator = appNavigator
  }

  /**
   * Get the {@link AppNavigator} for this component.
   *
   * @returns {AppNavigator} The {@link AppNavigator} for this component.
   */
  static getAppNavigator () {
    return this.appNavigator
  }

  /**
   * Get an unlocalized version of a button.
   *
   * @private
   * @param {string} buttonId - The ID of the {@link Button} to unlocalize.
   * @example
   * // returns 'ButtonIdentifier'
   * NavigationBar._unlocalizeButtonId('RegisteredRoute.ButtonIdenfifier')
   */
  static _unlocalizeButtonId (buttonId) {
    return buttonId.substring(this.route.length + 1)
  }

  /**
   * Determine whether to dispatch an <code>onNavButtonPress</code> event for a given buttonId.
   *
   * @private
   * @param {string} buttonId - The ID of the {@link Button}.
   */
  static _shouldDispatchButtonPressEvent (buttonId) {
    const lastDot = buttonId.lastIndexOf('.')
    const buttonRoute = lastDot > -1 ? buttonId.substring(0, lastDot) : ''
    return buttonRoute === this.getRegisteredRoute()
  }

  /**
   * Make a call to <code>onNavButtonPress(buttonId)</code> (if available) whenever a button is
   * pressed in the navigation bar.  This is called automatically whenever an event
   * is fired by the OnNavButtonClickEventListener.
   *
   * If a subclassed instance contains the <code>onNavButtonPress</code> method, that will be
   * called, otherwise the class's static method will be called.
   *
   * @private
   * @param {string} buttonId - The ID of the {@link Button} that was pressed.
   */
  static _handleNavButtonPress (buttonId) {
    if (this.constructor._shouldDispatchButtonPressEvent(buttonId)) {
      const handler = this.onNavButtonPress || this.constructor.onNavButtonPress
      if (handler) {
        handler.bind(this)(this.constructor._unlocalizeButtonId(buttonId))
      }
    }
  }

  /**
   * Get a localized version of the navigation bar for the given route.
   *
   * @private
   * @instance
   * @param {string} routeName - The name of the current route.
   * @param {NavigationBar} navigationBar - The {@link NavigationBar} object to localize.
   * @returns {NavigationBar} A {@link NavigationBar} object with the IDs of buttons updated to
   * pertain to the given route.
   */
  static _localizeNavigationBar (routeName, navigationBar) {
    return navigationBar ? {
      ...navigationBar,
      buttons: navigationBar.buttons
        ? navigationBar.buttons.map(button => ({ ...button, id: `${routeName}.${button.id}` }))
        : undefined
    } : {}
  }

  /**
   * Get the navigation bar for the given route.
   *
   * @private
   * @static
   * @param {Object} jsonPayload - The JSON payload for the current route.
   * @returns {NavigationBar} A {@link NavigationBar} object for the given route.
   */
  static _getNavigationBar (jsonPayload) {
    return {
      ...this.navigationOptions,
      title: this.getDynamicTitle(jsonPayload) || this.navigationOptions.title,
    }
  }

  /**
   * Get a localized version of the navigation bar for this route.
   *
   * @private
   * @instance
   * @param {Object} jsonPayload - The JSON payload for the current route.
   * @returns {NavigationBar} A {@link NavigationBar} object with the IDs of buttons updated to
   * pertain to the current route.
   */
  static _getLocalizedNavigationBar (jsonPayload) {
    return this._localizeNavigationBar(this.route, this._getNavigationBar(jsonPayload))
  }

  /**
   * Calculate the title for the current route based on the JSON payload.
   * Must be overriden in subclasses.
   *
   * @abstract
   * @static
   * @param {Object} jsonPayload - The JSON payload for the current route.
   */
  static getDynamicTitle (jsonPayload) {
  }

  /**
   * Handle button press events.
   * Must be overriden in subclasses.
   *
   * @abstract
   * @static
   * @param {string} buttonId - The ID of the button which was pressed.
   */
  static onNavButtonPress (buttonId) {
    console.warn(`\`onNavButtonPress(buttonId)\` was not overriden in ${this.constructor.name}, but a button press event was fired.`, { buttonId })
  }

  /**
   * Reset the navigation bar for the current screen to its defaults.
   *
   * @async
   * @instance
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * reset the navigation bar.
   */
  resetNavigationBar () {
    return EnNavigationApi.requests().update({
      path: this.constructor.route,
      navigationBar: this.constructor._getLocalizedNavigationBar()
    })
  }

  /**
   * Update the navigation bar for the current screen.
   *
   * @async
   * @instance
   * @param {NavigationBar} navigationBar - The {@link NavigationBar} object.
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * update the navigation bar.
   */
  updateNavigationBar (navigationBar) {
    return EnNavigationApi.requests().update({
      path: this.constructor.route,
      navigationBar: this.constructor._localizeNavigationBar(this.constructor.route, navigationBar)
    })
  }

  /**
   * Navigate to a given route.
   *
   * @async
   * @instance
   * @param {Object} route - The route object that details where to navigate next.
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * navigate to the given route.
   */
  navigate (route) {
    return EnNavigationApi.requests().navigate(route)
  }

  /**
   * Navigate to an internal screen.
   *
   * @async
   * @instance
   * @param {string} screenName - The name of the screen to navigate to; these names
   * should be defined in the initial {@link AppNavigator} setup.
   * @param {Object} [jsonPayload] - (optional) The JSON payload with props to send to the new
   * screen.
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * navigate to the new screen.
   */
  navigateInternal (screenName, jsonPayload) {
    if (!this.constructor.getAppNavigator()) {
      throw errors.invalidAppNavigator
    }
    if (!(this.constructor.getAppNavigator() instanceof AppNavigator)) {
      throw errors.invalidAppNavigator
    }
    if (!this.constructor.getAppNavigator().screens || Object.keys(this.constructor.getAppNavigator().screens).length < 1) {
      throw errors.noValidScreens
    }
    if (!screenName) {
      throw errors.noScreenName
    }
    if (!Object.keys(this.constructor.getAppNavigator().screens).includes(screenName)) {
      throw errors.invalidScreenName(screenName)
    }

    return EnNavigationApi.requests().navigate({
      path: this.constructor.getAppNavigator().screens[screenName].getRegisteredRoute(),
      navigationBar: this.constructor.getAppNavigator().screens[screenName]._getLocalizedNavigationBar(jsonPayload),
      jsonPayload: JSON.stringify(jsonPayload || {})
    })
  }

  /**
   * Go back to a specified screen.
   *
   * @async
   * @instance
   * @param {string} screenName - The name of the screen to navigate to; these names
   * should be defined in the initial {@link AppNavigator} setup.
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * go back to the specified screen.
   */
  backTo (screenName) {
    if (!this.constructor.getAppNavigator()) {
      throw errors.invalidAppNavigator
    }
    if (!(this.constructor.getAppNavigator() instanceof AppNavigator)) {
      throw errors.invalidAppNavigator
    }
    if (!this.constructor.getAppNavigator().screens || Object.keys(this.constructor.getAppNavigator().screens).length < 1) {
      throw errors.noValidScreens
    }
    if (!screenName) {
      throw errors.noScreenName
    }
    if (!Object.keys(this.constructor.getAppNavigator().screens).includes(screenName)) {
      throw errors.invalidScreenName(screenName)
    }

    return EnNavigationApi.requests().back({
      path: this.constructor.getAppNavigator().screens[screenName].getRegisteredRoute()
    })
  }

  /**
   * Go back one screen.
   *
   * @async
   * @instance
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * go back one screen.
   */
  back () {
    return EnNavigationApi.requests().back()
  }

  /**
   * Finish this flow.
   *
   * @async
   * @instance
   * @param {Object} [payload] - (optional) The JSON payload to send to the native activity or view
   * controller that launched the flow.
   * @return {Promise} A <code>Promise</code> which will resolve or reject upon attempting to
   * finish the current flow.
   */
  finish (payload) {
    return EnNavigationApi.requests().finish(JSON.stringify(payload || {}))
  }
}

export default Component