ICIJ/datashare-client

View on GitHub
src/core/Core.js

Summary

Maintainability
A
B
+4 hrs
Test Coverage
// BootstrapVue recommends using this
import 'mutationobserver-shim'

import compose from 'lodash/fp/compose'
import Murmur from '@icij/murmur-next'
import VCalendar from 'v-calendar'
import VueScrollTo from 'vue-scrollto'
import VueShortkey from 'vue3-shortkey'
import VueEllipseProgress from 'vue-ellipse-progress'
import Vue3Toastify, { toast } from 'vue3-toastify'
import { createBootstrap } from 'bootstrap-vue-next'
import { createVueWait } from 'vue-wait'
import { createApp, defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { createWebHashHistory, createRouter } from 'vue-router'
import { iteratee } from 'lodash'

import ComponentsMixin from './ComponentsMixin'
import FiltersMixin from './FiltersMixin'
import HooksMixin from './HooksMixin'
import I18nMixin from './I18nMixin'
import PipelinesMixin from './PipelinesMixin'
import ProjectsMixin from './ProjectsMixin'
import WidgetsMixin from './WidgetsMixin'

import { dispatch, EventBus } from '@/utils/event-bus'
import { getMode, MODE_NAME } from '@/mode'
import { routes } from '@/router'
import { storeBuilder } from '@/store/storeBuilder'
import Auth from '@/api/resources/Auth'
import ToastBody from '@/components/ToastBody'
import guards from '@/router/guards'
import messages from '@/lang/en'
import settings from '@/utils/settings'

class Base {}
const Behaviors = compose(
  ComponentsMixin,
  FiltersMixin,
  HooksMixin,
  I18nMixin,
  PipelinesMixin,
  ProjectsMixin,
  WidgetsMixin
)(Base)

/**
  @class
  @classdesc Class representing the core application with public methods for plugins.
  @mixes FiltersMixin
  @mixes HooksMixin
  @mixes I18nMixin
  @mixes PipelinesMixin
  @mixes ProjectsMixin
  @mixes WidgetsMixin
  @typicalname datashare
*/
class Core extends Behaviors {
  /**
   * Create an application
   * @param api - Datashare api interface
   * @param mode - mode of authentication ('local' or 'server'
   */
  constructor(api = null, mode = getMode(MODE_NAME.LOCAL)) {
    super()
    const Root = defineComponent({ name: 'Root', template: '<router-view></router-view>' })
    this._vue = createApp(Root)
    this._api = api
    this._store = storeBuilder(api)
    this._auth = new Auth(mode, this._api)
    // Setup deferred state
    this.defer()
  }
  /**
   * Add a Vue plugin to the app
   * @param {Object} Plugin - The actual Vue plugin class
   * @param {Object} options - Option to pass to the plugin
   * @returns {Core} the current instance of Core
   */
  use(Plugin, options) {
    this.vue.use(Plugin, options)
    return this
  }
  /**
   * Configure all default Vue plugins for this application
   * @returns {Core} the current instance of Core
   */
  useAll() {
    this.useVuex()
    this.useI18n()
    this.useBootstrapVue()
    this.useCommons()
    this.useWait()
    this.useCore()
    return this
  }
  /**
   * Configure vue-i18n plugin
   * @returns {Core} the current instance of Core
   */
  useI18n() {
    this._i18n = createI18n({
      warnHtmlInMessage: 'off',
      warnHtmlMessage: 'off',
      globalInjection: true,
      legacy: true,
      locale: settings.defaultLocale,
      fallbackLocale: settings.defaultLocale,
      messages: {
        [settings.defaultLocale]: messages
      }
    })
    this.use(this._i18n)
    return this
  }
  /**
   * Configure bootstrap-vue plugin
   * @returns {Core} the current instance of Core
   */
  useBootstrapVue() {
    this._bootstrapVue = createBootstrap({ components: true, directives: true })
    this.use(this.bootstrapVue)
    return this
  }
  /**
   * Configure vue-router plugin
   * @returns {Core} the current instance of Core
   */
  useRouter() {
    const history = createWebHashHistory()
    this._router = createRouter({ routes, history })
    this.use(this.router)
    guards(this)
    return this
  }
  /**
   * Configure vuex plugin
   * @returns {Core} the current instance of Core
   */
  useVuex() {
    this.use(this.store)
    return this
  }
  /**
   * Configure most common Vue plugins (Murmur, VueShortkey, VueScrollTo and VueCalendar)
   * @returns {Core} the current instance of Core
   */
  useCommons() {
    // Murmur is loaded without installing Vue i18n and Bootstrap Vue
    // to avoid adding them twice to the Vue instance.
    this.use(Murmur, { useI18n: false, useBootstrap: false })
    // Common plugins
    this.use(Vue3Toastify, { clearOnUrlChange: false, hideProgressBar: false, autoClose: 5000 })
    this.use(VueShortkey, { prevent: settings.hotKeyPrevented })
    this.use(VueScrollTo)
    this.use(VueEllipseProgress)
    // Setup VCalendar manually since Webpack is not compatible with
    // dynamic chunk import with third party modules.
    // @see https://github.com/nathanreyes/v-calendar/issues/413#issuecomment-530633437
    this.use(VCalendar, { componentPrefix: 'vc' })
    return this
  }
  /**
   * Configure vue-wait plugin
   * @returns {Core} the current instance of Core
   */
  useWait() {
    this._wait = createVueWait({ useVuex: true })
    this.use(this.wait)
    return this
  }
  /**
   * Add a $core property to the instance's Vue
   * @returns {Core} the current instance of Core
   */
  useCore() {
    this._plugin = this.buildCorePlugin()
    this.use(this.plugin)
    return this
  }
  /**
   * Build a VueCore instance with the current Core instance
   * as parameter of the global properties.
   * @returns {VueCore}
   */
  buildCorePlugin() {
    const core = this
    return class VueCore {
      static install(app) {
        app.config.globalProperties.$core = core
        app.config.compilerOptions.whitespace = 'preserve'
        // inject a globally available $toast object
        app.config.globalProperties.$toast = {
          toast(body, { title = null, href = null, linkLabel = null, ...options } = {}) {
            const closeOnClick = options.closeOnClick ?? !href
            const props = { title, body, href, linkLabel }
            const toastProps = { closeOnClick, ...options }
            toast?.(({ closeToast, toastProps }) => h(ToastBody, { closeToast, toastProps, ...props }), toastProps)
          },
          error(body, options) {
            this.toast(body, { ...options, type: 'error' })
          },
          danger(body, options) {
            this.toast(body, { ...options, type: 'error' })
          },
          warning(body, options) {
            this.toast(body, { ...options, type: 'warning' })
          },
          info(body, options) {
            this.toast(body, { ...options, type: 'info' })
          },
          success(body, options) {
            this.toast(body, { ...options, type: 'success' })
          }
        }
      }
    }
  }
  /**
   * Load settings from the server and instantiate most the application configuration.
   * @async
   * @fullfil {Core} - The instance of the core application
   * @reject {Object} - The Error object
   * @returns {Promise<Object>}
   */
  async configure() {
    try {
      // Override Murmur default value for content-placeholder
      this.config.set('content-placeholder.rows', settings.contentPlaceholder.rows)
      // Get the config object
      await this.loadSettings()
      // Create the default project for the current user or redirect to login
      if (this.mode.modeName !== 'server') {
        if (!(await this.defaultProjectExists())) {
          await this.createDefaultProject()
        }
      }
      this._auth = new Auth(this.mode)
      // Set the default project
      if (!this.store.state.search.indices.length) {
        this.store.commit('search/indices', [this.getDefaultProject()])
      }
      // Check if "Download" functionality is available for the selected project
      // Because otherwise, if the FilterPanel is closed, it is never called
      await this.store.dispatch('downloads/fetchIndicesStatus')
      // Initialize current locale
      await this.initializeI18n()
      // Hold a promise that is resolved when the core is configured
      return this.ready && this._readyResolve(this)
    } catch (error) {
      return this.ready && this._readyReject(error)
    }
  }

  getDefaultProject() {
    const userProjects = this.config.get('projects', []).map(iteratee('name'))
    if (userProjects.length === 0) {
      return ''
    }
    const defaultProject = this.config.get('defaultProject', '')
    return userProjects.indexOf(defaultProject) === -1 ? userProjects[0] : defaultProject
  }

  /**
   * Mount the instance's vue application
   * @param {String} [selector=#app] - Query selector to the mounting point
   * @returns {Vue} The instantiated Vue
   */
  mount(selector = '#app') {
    this.vue.mount(selector)
    // Return an instance of the int constructor we receive.
    return this.vue
  }
  /**
   * Build a promise to be resolved when the application is configured.
   */
  defer() {
    this._ready = new Promise((resolve, reject) => {
      this._readyResolve = resolve
      this._readyReject = reject
    })
    // Notify the document the core is ready
    this._ready.then(() => this.dispatch('ready'))
  }
  /**
   * Dispatch an event from the document root, passing the core application through event message.
   * @param {String} name - Name of the event to fire
   * @param {...Mixed} args - Additional params to pass to the event
   * @returns {Core} the current instance of Core
   */
  dispatch(name, ...args) {
    dispatch(name, { app: this, core: this, ...args })
    return this
  }
  /**
   * Get the current signed user.
   * @async
   * @fullfil {Object} Current user
   * @returns {Promise<Object>}
   */
  getUser() {
    return this.api.getUser()
  }
  /**
   * Get and update user definition in place
   * @async
   * @returns {Promise}
   */
  async loadUser() {
    // Load the user
    this.config.merge(await this.getUser())
  }
  /**
   * Get settings (both from the server settings and the current mode)
   * @async
   * @returns {Promise}
   */
  async loadSettings() {
    // Get the config object
    const serverSettings = await this.api.getSettings()
    // Load the user and update the settings accordingly
    await this.loadUser()
    // Murmur exposes a config attribute which shares a Config object
    // with the current vue instance.
    this.config.merge(getMode(serverSettings.mode))
    // The backend can yet override some configuration
    this.config.merge(serverSettings)
  }
  /**
   * Append the given title to the page title
   * @param {String} title - Title to append to the page
   * @param {String} [suffix=Datashare] - Suffix to the title
   */
  setPageTitle(title = null, suffix = 'Datashare') {
    if (document && document.title) {
      document.title = title ? `${title} - ${suffix}` : suffix
    }
  }
  /**
   * Register a callback to an event using the EventBus singleton.
   * @param {String} event
   * @param {*} callback
   */
  on(event, callback) {
    EventBus.on(event, callback)
  }
  /**
   * Unregister a callback to an event using the EventBus singleton.
   * @param {String} event
   * @param {*} callback
   */
  off(event, callback) {
    EventBus.off(event, callback)
  }
  /**
   * Emit an event using the EventBus singleton.
   * @param {String} event
   * @param {*} payload
   */
  emit(event, payload) {
    EventBus.emit(event, payload)
  }
  /**
   * Get a promise that is resolved when the application is ready
   * @fullfil {Object} The actual application core instance.
   * @type {Promise<Object>}
   */
  get ready() {
    if (!this._ready) {
      this.defer()
    }
    return this._ready
  }
  /**
   * The application core instance. Deprecated in favor or the `core` property.
   * @type {Core}
   * @deprecated
   */
  get app() {
    return this
  }
  /**
   * The application core instance
   * @type {Core}
   */
  get core() {
    return this
  }
  /**
   * The Bootstrap Vue plugin instance.
   * @returns {Plugin}
   */
  get bootstrapVue() {
    return this._bootstrapVue
  }
  /**
   * The I18n instance
   * @type {I18n}
   */
  get i18n() {
    return this._i18n
  }
  /**
   * The VueRouter instance
   * @type {VueRouter}
   */
  get router() {
    return this._router
  }
  /**
   * The Vuex instance
   * @type {Vuex.Store}
   */
  get store() {
    return this._store
  }
  /**
   * The CorePlugin instance
   * @returns {*}
   */
  get plugin() {
    return this._plugin
  }
  /**
   * The Auth module instance
   * @type {Auth}
   */
  get auth() {
    return this._auth
  }
  /**
   * The configuration object provided by Murmur
   * @type {Object}
   */
  get config() {
    return Murmur.config
  }
  /**
   * The Datashare api interface
   * @type {Api}
   */
  get api() {
    return this._api
  }
  /**
   * The Vue app
   * @type {Vue}
   */
  get vue() {
    return this._vue
  }
  /**
   * The VueWait
   * @type {VueWait}
   */
  get wait() {
    return this._wait
  }
  /**
   * Get current Datashare mode
   * @type {String}
   */
  get mode() {
    return getMode(this.config.get('mode'))
  }
  /**
   * instantiate a Core class (useful for chaining usage or mapping)
   * @param {...Mixed} options - Options to pass to the Core constructor
   * @returns {Core}
   */
  static init(...options) {
    return new Core(...options)
  }
}

// Force usage of Core.init instead of constructor
const coreInit = Object.freeze({
  isInstanceOfCore: (object) => object instanceof Core,
  init: Core.init
})

export { Core }
export default coreInit