Skip to content

Commit

Permalink
feat(web-runtime): introduce skeleton app and runtime-hooks
Browse files Browse the repository at this point in the history
in the proccess of adding a skeleton application we faced the issue of
not having real runtime hooks.
this introduces 3 hooks and exposes a
dedicated runtime api to the application.

this is fully backwards
compatible.
  • Loading branch information
fschade committed Aug 30, 2021
1 parent 4f5a4a7 commit d57e819
Show file tree
Hide file tree
Showing 18 changed files with 920 additions and 327 deletions.
5 changes: 3 additions & 2 deletions packages/web-app-skeleton/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ const injectExtensions = async api => {
console.log('# the server answered and we have all information to register a extension')
console.log('#############################################################################')

api.registerExtension(appInfo.id, {
api.announceExtension({
extension: 'txt',
isFileEditor: true,
newFileMenu: {
menuTitle($gettext) {
return $gettext('Extension from skeleton')
Expand Down Expand Up @@ -62,7 +63,7 @@ export default {
}
}
],
async mounted({ api }) {
async mounted(api) {
await injectExtensions(api)
}
}
2 changes: 1 addition & 1 deletion packages/web-container/index.html.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
})
requirejs(['web-runtime'], function (runtime) {
runtime.exec()
runtime.bootstrap('config.json').then(runtime.renderSuccess).catch(runtime.renderFailure)
})
</script>
</body>
Expand Down
266 changes: 266 additions & 0 deletions packages/web-runtime/src/container/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import VueRouter, { RouteConfig } from 'vue-router'
import clone from 'lodash-es/clone'
import {
ClassicApplicationScript,
RuntimeApi,
ApplicationNavigationItem,
ApplicationQuickActions
} from './types'
import { ApiError } from './error'
import { get, isEqual, isObject, isArray } from 'lodash-es'
import { Store } from 'vuex'
import Vue, { Component } from 'vue'
import { Wormhole } from 'portal-vue'

/**
* inject application specific routes into runtime
*
* @param applicationId
* @param router
* @param routes
*/
const announceRoutes = (applicationId: string, router: VueRouter, routes: RouteConfig[]): void => {
if (!isArray(routes)) {
throw new ApiError("routes can't be blank")
}

const applicationRoutes = routes.map(applicationRoute => {
if (!isObject(applicationRoute)) {
throw new ApiError("route can't be blank", applicationRoute)
}

const route = clone(applicationRoute)
route.name = applicationId === route.name ? route.name : `${applicationId}-${route.name}`
route.path = `/${encodeURI(applicationId)}${route.path}`

if (route.children) {
route.children = route.children.map(childRoute => {
if (!isObject(applicationRoute)) {
throw new ApiError("route children can't be blank", applicationRoute, childRoute)
}

const route = clone(childRoute)
route.name = `${applicationId}-${childRoute.name}`
return route
})
}

return route
})

router.addRoutes(applicationRoutes)
}

/**
* inject application specific navigation items into runtime
*
* @param applicationId
* @param store
* @param navigationItems
*/
const announceNavigationItems = (
applicationId: string,
store: Store<unknown>,
navigationItems: ClassicApplicationScript['navItems']
): void => {
if (!isObject(navigationItems)) {
throw new ApiError("navigationItems can't be blank")
}

store.commit('SET_NAV_ITEMS_FROM_CONFIG', {
extension: applicationId,
navItems: navigationItems
})
}

/**
* inject application specific extension into runtime
*
* @param applicationId
* @param store
* @param extension
*/
const announceExtension = (
applicationId: string,
store: Store<unknown>,
extension: { [key: string]: unknown }
): void => {
store.commit('REGISTER_EXTENSION', { app: applicationId, extension })
}

/**
* inject application specific translations into runtime
*
* @param translations
* @param appTranslations
* @param supportedLanguages
*/
const announceTranslations = (
supportedLanguages: { [key: string]: string },
translations: unknown,
appTranslations: ClassicApplicationScript['translations']
): void => {
if (!isObject(translations)) {
throw new ApiError("translations can't be blank")
}

Object.keys(supportedLanguages).forEach(lang => {
if (translations[lang] && appTranslations[lang]) {
Object.assign(translations[lang], appTranslations[lang])
}
})
}

/**
* inject application specific quickActions into runtime
*
* @param store
* @param quickActions
*/
const announceQuickActions = (
store: Store<unknown>,
quickActions: ClassicApplicationScript['quickActions']
): void => {
if (!isObject(quickActions)) {
throw new ApiError("quickActions can't be blank")
}

store.commit('ADD_QUICK_ACTIONS', quickActions)
}

/**
* inject application specific store into runtime
*
* @param applicationName
* @param store
* @param applicationStore
*/
const announceStore = (
applicationName: string,
store: Store<unknown>,
applicationStore: unknown
): void => {
const obtainedStore: Store<unknown> = get(applicationStore, 'default', applicationStore)

if (!isObject(obtainedStore)) {
throw new ApiError("store can't be blank")
}

store.registerModule(applicationName, obtainedStore)
}

/**
* open a wormhole portal, this wraps vue-portal
*
* @param instance
* @param applicationId
* @param toApp
* @param toPortal
* @param order
* @param components
*/
const openPortal = (
applicationId: string,
instance: typeof Vue.prototype,
toApp: string,
toPortal: string,
order: number,
components: Component[]
): void => {
Wormhole.open({
to: ['app', toApp, toPortal].filter(Boolean).join('.'),
from: ['app', applicationId, toPortal, order].filter(Boolean).join('.'),
order: order,
passengers: components.map(instance.$createElement)
})
}

/**
* expose store to the application
*
* @deprecated use with caution
*
* @param store
*/
const requestStore = (store: Store<unknown>): Store<unknown> => {
if (isEqual(process.env.NODE_ENV, 'development')) {
console.warn('requestStore // store api is deprecated, use with caution')
}

return store
}

/**
* expose router to the application
*
* @deprecated use with caution
*
* @param router
*/
const requestRouter = (router: VueRouter): VueRouter => {
if (isEqual(process.env.NODE_ENV, 'development')) {
console.warn('requestRouter // router api is deprecated, use with caution')
}

return router
}

/**
* exposed runtime api, this wraps all available api actions in a closure and provide application
* specific data to the implementations.
*
* each application get it's own provisioned api!
*
* @param applicationName
* @param applicationId
* @param store
* @param router
* @param translations
* @param supportedLanguages
*/
export const buildRuntimeApi = ({
applicationName,
applicationId,
store,
router,
translations,
supportedLanguages
}: {
applicationName: string
applicationId: string
store: Store<unknown>
translations: unknown
router: VueRouter
supportedLanguages: { [key: string]: string }
}): RuntimeApi => {
if (!applicationName) {
throw new ApiError("applicationName can't be blank")
}

if (!applicationId) {
throw new ApiError("applicationId can't be blank")
}

return {
announceRoutes: (routes: RouteConfig[]): void => announceRoutes(applicationId, router, routes),
announceNavigationItems: (navigationItems: ApplicationNavigationItem[]): void =>
announceNavigationItems(applicationId, store, navigationItems),
announceTranslations: (appTranslations: unknown): void =>
announceTranslations(supportedLanguages, translations, appTranslations),
announceQuickActions: (quickActions: ApplicationQuickActions): void =>
announceQuickActions(store, quickActions),
announceStore: (applicationStore: Store<unknown>): void =>
announceStore(applicationName, store, applicationStore),
announceExtension: (extension: { [key: string]: unknown }): void =>
announceExtension(applicationId, store, extension),
requestStore: (): Store<unknown> => requestStore(store),
requestRouter: (): VueRouter => requestRouter(router),
openPortal: (
instance: typeof Vue.prototype,
toApp: string,
toPortal: string,
order: number,
components: Component[]
): void => openPortal(applicationId, instance, toApp, toPortal, order, components)
}
}
103 changes: 103 additions & 0 deletions packages/web-runtime/src/container/application/classic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { ClassicApplicationScript, RuntimeApi } from '../types'
import { buildRuntimeApi } from '../api'
import Vue from 'vue'
import { isFunction, isObject } from 'lodash-es'
import { NextApplication } from './next'
import { Store } from 'vuex'
import VueRouter from 'vue-router'
import { RuntimeError } from '../error'

/**
* this wraps a classic application structure into a next application format.
* it is fully backward compatible and will stay around as a fallback.
*/
class ClassicApplication extends NextApplication {
private readonly applicationScript: ClassicApplicationScript

constructor(runtimeApi: RuntimeApi, applicationScript: ClassicApplicationScript) {
super(runtimeApi)
this.applicationScript = applicationScript
}

prepare(): Promise<void> {
const { routes, navItems, translations, quickActions, store } = this.applicationScript

routes && this.runtimeApi.announceRoutes(routes)
navItems && this.runtimeApi.announceNavigationItems(navItems)
translations && this.runtimeApi.announceTranslations(translations)
quickActions && this.runtimeApi.announceQuickActions(quickActions)
store && this.runtimeApi.announceStore(store)

return Promise.resolve(undefined)
}

register(): Promise<void> {
return Promise.resolve(undefined)
}

mounted(instance: Vue): Promise<void> {
const { mounted: mountedHook } = this.applicationScript
isFunction(mountedHook) &&
mountedHook({
portal: {
open: (...args) => this.runtimeApi.openPortal.apply(instance, [instance, ...args])
},
store: this.runtimeApi.requestStore(),
router: this.runtimeApi.requestRouter(),
...this.runtimeApi
})

return Promise.resolve(undefined)
}
}

/**
*
* @param applicationPath
* @param store
* @param router
* @param translations
* @param supportedLanguages
*/
export const convertClassicApplication = async ({
applicationScript,
store,
router,
translations,
supportedLanguages
}: {
applicationScript: ClassicApplicationScript
store: Store<unknown>
router: VueRouter
translations: unknown
supportedLanguages: { [key: string]: string }
}): Promise<NextApplication> => {
const { appInfo } = applicationScript

if (!isObject(appInfo)) {
throw new RuntimeError("appInfo can't be blank")
}

const { id: applicationId, name: applicationName } = appInfo

if (!applicationId) {
throw new RuntimeError("appInfo.id can't be blank")
}

if (!applicationName) {
throw new RuntimeError("appInfo.name can't be blank")
}

const runtimeApi = buildRuntimeApi({
applicationName,
applicationId,
store,
router,
translations,
supportedLanguages
})

await store.dispatch('registerApp', applicationScript.appInfo)

return new ClassicApplication(runtimeApi, applicationScript)
}
Loading

0 comments on commit d57e819

Please sign in to comment.