/**
* Mount helpers for Vue components
*
* @module mountUtils
* @memberOf module:@unitybase/adminui-vue
*/
module.exports = {
mountTab,
mountModal,
mountContainer,
mountTableEntity
}
/* global $App, Ext */
const Vue = require('vue')
const UB = require('@unitybase/ub-pub')
const Dialog = require('element-ui').Dialog
const UDrawer =
require('../../components/controls/UDrawer/UDrawer.vue').default
const uDialogs = require('../uDialogs')
/**
* Mount form in modal. Provide `isModal: true` to the child components, child components can inject it as `parentIsModal`
*
* @param {object} cfg
* @param {Vue.Component} cfg.component Form component
* @param {object} cfg.props Form component props
* @param {object[]} [cfg.mixins] Form component mixins
* @param {Vuex.Store} [cfg.store] Store
* @param {string} cfg.title Title
* @param {Validator} [cfg.validator] Validator
* @param {string} [cfg.modalId] Modal id, needed for detect same dialog and show/maximize if it was hidden/minimized
* @param {string} [cfg.modalClass] Modal class
* @param {string} [cfg.modalWidth] Modal width
* @param {object} cfg.provide Regular object which provide all props what passed in it
* @param {Function} [cfg.beforeClose] Callback, called before form is closed (not forcefully).
* This implementation will replace the standard one, which asks the user to save or discard changes or continue to edit
* @param {Function} [cfg.onClose] Async callback, called (and awaited) before form is destroyed with 2 args:
* (ID: number|null, store: Vuex.Store); In case form is in isNew state, ID value is null, otherwise - an ID from store
* @param cfg.modalType
* @param cfg.target
*/
function mountModal (cfg) {
let {
component,
mixins,
props,
store,
title: titleText,
validator,
modalId,
modalClass = 'ub-dialog__reset-padding',
modalWidth,
modalType = 'dialog', // 'dialog' or 'drawer'
provide,
beforeClose = baseBeforeClose,
onClose
} = cfg
if (modalId) {
const modal = $App.viewport.centralPanel.ModalManager.getModalByModalId(modalId)
if (modal) {
modal.componentInstance.dialogVisible = true
if (modalType === 'drawer') {
modal.componentInstance.$refs.dialog.toggleMinimized()
}
return
}
}
modalClass += ' ub-dialog__min-width'
if (!modalWidth) {
modalClass += ' ub-dialog__max-width'
}
const instance = Vue.component('ModalComponent', {
devtoolsTags: ['form', 'modal', ...(store ? ['customStore'] : [])],
cfg,
store,
mixins,
provide () {
return {
$v: validator ? validator.getValidationState() : undefined,
validator,
$formServices: this.$formServices,
isModal: true,
...provide
}
},
data () {
return {
modalId,
dialogVisible: false,
titleText,
localIsDirty: undefined,
localSaveAction: undefined,
localCloseAction: () => {},
closing: false
}
},
computed: {
isDirty () {
if (this.localIsDirty !== undefined) {
return this.localIsDirty
} else if (this.$store) {
return this.$store.getters.isDirty
}
return false
},
isNew () {
return this.$store ? this.$store.state.isNew : false
},
title () {
const prefix = this.isDirty ? '* ' : ''
const suffix = this.isNew ? ` (${UB.i18n('dobavlenie')})` : ''
return prefix + this.$ut(this.titleText) + suffix
},
$formServices () {
return {
setTitle: this.setTitle,
setLocalSaveAction: (saveAction) => {
this.localSaveAction = saveAction
},
setLocalCloseAction: (closeAction) => {
this.localCloseAction = closeAction
},
setLocalIsDirty: (isDirty) => {
this.localIsDirty = isDirty
},
close: (argBeforeClose = beforeClose) => {
this.closing = true
argBeforeClose({
close: () => {
this.closing = false
this.dialogVisible = false
this.localCloseAction()
},
save: () => {
this.closing = false
this.localSaveAction()
},
cancel: () => {
this.closing = false
},
store,
isDirty: this.isDirty
})
},
forceClose: () => {
this.dialogVisible = false
}
}
}
},
mounted () {
if (this.$store && 'SET_FORM_SERVICES' in this.$store._mutations) {
this.$store.commit('SET_FORM_SERVICES', this.$formServices)
}
},
async beforeDestroy () {
if (onClose && typeof onClose === 'function' && store) {
await onClose(store.state.isNew ? null : store.state.data.ID, store)
}
},
methods: {
setVisible (val) {
this.dialogVisible = val
},
setTitle (value) {
this.titleText = value
}
},
render (h) {
let modalComponent = Dialog
let componentProps = {
title: this.title,
visible: this.dialogVisible,
width: modalWidth,
closeOnClickModal: false,
beforeClose: (done) => {
this.closing = true
beforeClose({
close: () => {
this.closing = false
done()
this.localCloseAction()
},
save: () => {
this.closing = false
this.localSaveAction()
},
cancel: () => {
this.closing = false
},
store,
isDirty: this.isDirty
})
},
closing: this.closing,
...props
}
let modalListeners = {
closed: () => {
$App.viewport.centralPanel.ModalManager.unregisterModal(this)
},
'update:visible': this.setVisible
}
let scopedSlots = {}
const children = []
const componentListeners = {
close: () => {
this.dialogVisible = false
}
}
switch (modalType) {
case 'drawer':
modalComponent = UDrawer
componentProps = { ...componentProps, size: modalWidth }
modalListeners = {
...modalListeners,
open: () => this.setVisible(true),
close: () => this.setVisible(false)
}
scopedSlots = {
...scopedSlots,
default: (scopedProps) =>
h(component, {
props: { ...scopedProps, ...props, title: this.title },
on: componentListeners
})
}
break
default:
children.push(
h(component, {
props,
on: componentListeners
})
)
break
}
return h(
modalComponent,
{
ref: 'dialog',
class: modalClass,
props: componentProps,
on: modalListeners,
scopedSlots
},
children
)
}
})
$App.viewport.centralPanel.ModalManager.registerModal(instance).then(
(modal) => {
modal.componentInstance.dialogVisible = true
}
)
}
/**
* Mount form in tab
* @param {object} cfg
* @param {Vue.Component} cfg.component Form component
* @param {object} cfg.props Form component props
* @param {object[]} [cfg.mixins] Form component mixins
* @param {Vuex.Store} [cfg.store] Store
* @param {string} cfg.title Title
* @param {string} cfg.tabId navbar tab ID
* @param {Validator} [cfg.validator] Validator
* @param {string} [cfg.uiTag] Optional UI Tag for tracking subsystem
* @param {object} cfg.provide Regular object which provide all props what passed in it
* @param {boolean} [cfg.openInBackgroundTab=false] If `true` - the tab with a newly opened form does not become active
* @param {Function} [cfg.beforeClose] Callback, called before form is closed (not forcefully).
* This implementation will replace the standard one, which asks the user to save or discard changes or continue to edit
* @param {Function} [cfg.onClose] Async callback, called (and awaited) before form is destroyed with 2 args:
* (ID: number|null, store: Vuex.Store); In case form is in isNew state, ID value is null, otherwise - an ID from store
* @param cfg.iconCls
* @param cfg.commandConfig
*/
async function mountTab (cfg) {
const {
component,
props,
mixins,
store,
validator,
title: titleText,
titleTooltip: titleTooltipText,
tabId,
uiTag,
provide,
openInBackgroundTab,
iconCls,
commandConfig,
beforeClose = baseBeforeClose,
onClose
} = cfg
const items = $App.viewport.centralPanel.items
const tabsCnt = items.length
if (
tabsCnt >= UB.connection.appConfig.uiSettings.adminUI.maxMainWindowTabOpened
) {
$App.dialogError('tabsCountLimitExceeded')
return
}
const newTabId = `${tabId}`
const existedTab = $App.viewport.centralPanel.getTabById(newTabId)
if (existedTab) {
$App.viewport.centralPanel.setActiveTab(existedTab)
return
}
const tab = $App.viewport.centralPanel.add({
commandConfig,
iconCls,
title: titleText,
titleTooltip: titleTooltipText,
id: newTabId,
closable: true,
uiTag,
renderFn: (h) => {
return h('div')
}
})
const instanceComponent = {
devtoolsTags: [
'form',
...(store ? ['customStore'] : []),
`entity:${commandConfig?.entity}`,
`formCode:${commandConfig?.formCode}`
],
cfg,
mixins,
provide () {
return {
$v: validator ? validator.getValidationState() : undefined,
validator,
$formServices: this.$formServices,
...provide
}
},
data () {
return {
titleText,
titleTooltipText
}
},
computed: {
$formServices () {
return {
setTitle: this.setTitle,
setTooltip: this.setTooltip,
close: tab.close.bind(tab),
forceClose () {
tab.forceClose = true
tab.close()
}
}
},
isDirty () {
if (this.$store) {
return this.$store.getters.isDirty
} else {
return false
}
},
isNew () {
if (this.$store) {
return this.$store.state.isNew
} else {
return false
}
},
title () {
const prefix = this.isDirty ? '* ' : ''
const suffix = this.isNew ? ` (${UB.i18n('dobavlenie')})` : ''
return prefix + this.$ut(this.titleText) + suffix
},
titleTooltip () {
return this.$ut(this.titleTooltipText) || this.title
}
},
watch: {
title: {
immediate: true,
handler (title) {
tab.setTitle(title)
}
}
},
mounted () {
if (this.$store && 'SET_FORM_SERVICES' in this.$store._mutations) {
this.$store.commit('SET_FORM_SERVICES', this.$formServices)
}
},
async beforeDestroy () {
if (onClose && typeof onClose === 'function' && store) {
await onClose(store.state.isNew ? null : store.state.data.ID, store)
}
},
methods: {
setTitle (title) {
this.titleText = title
},
setTooltip (tooltip) {
this.titleTooltipText = tooltip
tab.setTooltip(tooltip)
}
},
render: (h) => h(component, { props }),
store
}
const instance = Vue.component('MountTabWrapper', instanceComponent)
$App.viewport.centralPanel.changeTab({
tabData: {
id: tabId,
renderFn: (h) => h(instance)
}
})
tab.on('beforeClose', (currentTab) => {
if (currentTab.forceClose) return true
this.closing = true
beforeClose({
store,
close: () => {
this.closing = false
tab.forceClose = true
tab.close()
},
cancel: () => {
this.closing = false
},
isDirty: store?.getters?.isDirty || false
})
return false
})
if (!openInBackgroundTab) {
$App.viewport.centralPanel.setActiveTab(tab)
}
}
/**
* Show confirmation dialog
* @param {object} args
* @param {Vuex.Store} args.store
* @param {Function} args.close
* @param args.saveAction
* @param args.cancel
*/
function showConfirmationDialog ({ saveAction, close, cancel = () => {} }) {
const buttons = {
yes: saveAction ? UB.i18n('save') : UB.i18n('return'),
no: saveAction ? UB.i18n('doNotSave') : UB.i18n('close')
}
if (saveAction) {
buttons.cancel = UB.i18n('cancel')
}
uDialogs.dialog({
title: saveAction ? UB.i18n('unsavedData') : UB.i18n('warning!'),
msg: saveAction ? UB.i18n('confirmSave') : `${UB.i18n('lostData')} ${UB.i18n('doYouWantToContinue')}`,
type: 'warning',
buttons,
customClass: 'ub-dialog__confirmation'
}).then(answer => {
if (answer === 'yes' && saveAction) {
saveAction()?.then(close)
}
if (answer === 'yes' && !saveAction) {
close()
}
if (answer === 'no') {
close()
}
if (answer === 'cancel') {
cancel()
}
})
}
/**
* Check form isDirty, and is so - ask user to save od discard changes or continue to edit
* @param {Store} store Store
* @param {Function} close Callback for close
*/
function baseBeforeClose ({ store, close, save, cancel = () => {}, isDirty }) {
const saveAction = save || (store && 'save' in store._actions ? store.dispatch.bind(store, 'save') : null)
if (isDirty) {
showConfirmationDialog({ saveAction, close, cancel })
} else {
close()
}
}
/**
* Mount form directly into html container
* @param {object} cfg
* @param {Vue.Component} cfg.component Form component
* @param {object} cfg.props Form component props
* @param {object[]} [cfg.mixins] Form component mixins
* @param {Vuex.Store} [cfg.store] Store
* @param {object} cfg.provide Regular object which provide all props what passed in it
* @param {Ext.component | string} cfg.target Either id of html element or Ext component
* @param {Validator} [cfg.validator] validator
* @param {Function} [cfg.onClose] Async callback, called (and awaited) before form is destroyed with 2 args:
* (ID: number|null, store: Vuex.Store); In case form is in isNew state, ID value is null, otherwise - an ID from store
*/
function mountContainer (cfg) {
const {
component,
props,
mixins,
store,
provide,
target,
validator,
onClose
} = cfg
const $formServices = {
setTitle () {},
close () {},
forceClose () { }
}
const componentConfig = {
devtoolsTags: ['form', 'container', ...(store ? ['customStore'] : [])],
cfg,
store,
mixins,
provide () {
return {
$v: validator ? validator.getValidationState() : undefined,
validator,
$formServices,
...provide
}
},
mounted () {
if (this.$store && 'SET_FORM_SERVICES' in this.$store._mutations) {
this.$store.commit('SET_FORM_SERVICES', $formServices)
}
},
async beforeDestroy () {
if (onClose && typeof onClose === 'function' && store) {
await onClose(store.state.isNew ? null : store.state.data.ID, store)
}
},
render: (h) => h(component, { props })
}
const instance = target._isVue
? Vue.component('ContainerWrapper', componentConfig)
: new Vue(componentConfig)
if (target._isVue) {
target.previewComponent = instance
} else if (typeof target === 'string') {
const el = document.querySelector(`#${target}`)
if (!el) {
instance.$notify({
type: 'error',
message: `Can't find html element with ${target} id`,
duration: 3000
})
return
}
bindInstanceToParentDestroy(el, instance)
instance.$mount(`#${target}`)
} else if ('getId' in target) {
// Ext component
if (document.getElementById(`${target.getId()}-outerCt`)) {
instance.$mount(`#${target.getId()}-outerCt`)
} else if (document.getElementById(`${target.getId()}-innerCt`)) {
instance.$mount(`#${target.getId()}-innerCt`)
} else {
// tab panel without fake element inside - use -body
instance.$mount(`#${target.getId()}-body`)
}
// adding vue instance to basepanel
const basePanel = target.up('basepanel')
if (!basePanel.vueChilds) basePanel.vueChilds = []
basePanel.vueChilds.push(instance)
// this watcher helps parent ExtJS form to see vue form is dirty
const unWatch = instance.$store
? instance.$store.watch(
(state, getters) => getters.isDirty,
() => basePanel.updateActions(),
{ immediate: true }
)
: null
target.on('destroy', () => {
if (unWatch) unWatch()
instance.$destroy()
})
} else if (target instanceof HTMLElement) {
bindInstanceToParentDestroy(target, instance)
instance.$mount(target)
}
}
const DESTROY_CHILD_SYMBOL = Symbol('destroy-child')
/**
* @param {HTMLElement} element
* @param {Vue} instance
*/
function bindInstanceToParentDestroy (element, instance) {
const parentVueInstance = element.parentNode?.__vue__
if (!parentVueInstance) {
console.warn('Mounting target element does not contain parent with Vue instance. A potential memory leak exists because there is no hook to destroy the created instance')
return
}
const prevInstance = element.__vue__
if (prevInstance) {
// prevent existing several instances for the same element
prevInstance.$destroy()
const prevDestroyCb = parentVueInstance[DESTROY_CHILD_SYMBOL]
if (prevDestroyCb) {
parentVueInstance.$off('hook:beforeDestroy', prevDestroyCb)
}
}
parentVueInstance[DESTROY_CHILD_SYMBOL] = () => instance.$destroy()
parentVueInstance.$once('hook:beforeDestroy', parentVueInstance[DESTROY_CHILD_SYMBOL])
}
const UMasterDetailView = require('../../components/UMasterDetailView/UMasterDetailView.vue').default
/**
*
* @param cfg
*/
function getEntityName (cfg) {
if (!cfg.props.entityName && !cfg.props.repository) {
throw new Error('One of "props.entityName" or "props.repository" is required')
}
switch (typeof cfg.props.repository) {
case 'function':
return cfg.props.repository().entityName
case 'object':
return cfg.props.repository.entity
default:
return cfg.props.entityName
}
}
/**
* Mount UMasterDetailView
*
* @param {object} cfg Command config
* @param {object} cfg.props Props data
* @param {object} cfg.tabId Tab id
* @param {string} [cfg.uiTag] Optional UI Tag for tracking subsystem
* @param {string} [cfg.title] Tab title. Can contain macros `{attrName}`, such macros will be replaced by attributes values
* @param {object} cfg.props UMasterDetailView props
* @param {Function} [cfg.props.repository] Function which returns ClientRepository.
* Can be empty in case `props.entityName` is defined - it this case repository constructed automatically
* based on attributes with `defaultView: true`
* @param {string} [cfg.props.entityName] Name of entity. Ignored in case `props.repository` is defined
* @param {Array<string | UTableColumn>} [cfg.props.columns] Columns config.
* If empty will be constructed based on repository attributes.
* @param {TableScopedSlotsBuilder} [cfg.scopedSlots] Scoped slots
* @param {boolean} [cfg.isModal] Is modal
* @param {string} [cfg.shortcutCode] Shortcut code
* @param {string} [cfg.uiSettingKey] Used as a key part for storing table UI settings (ordering, column widths and visibility) into local storage.
* If empty (default) columns is not configurable
*/
function mountTableEntity (cfg) {
const mountTableEventParams = { cfg, entityName: getEntityName(cfg), continue: true }
/**
* Fired before the `showList` (dictionary shown) command. Handler can cancel command by set `params.continue = false`
*
* @example
* window.$App.on('portal:mountTableEntity:before', (params) => {
* if (params.entityName.startsWith('org_') && !$App.connection.userData.hasRole('LocalOrgManager')) {
* params.continue = false // prevent show dictionary
* $App.dialogError('recordNotExistsOrDontHaveRights')
* }
* })
* @event 'portal:mountTableEntity:before'
* @memberOf module:@unitybase/adminui-vue
* @param {object} cfg Config, passed to showList
* @param {string} entityName Name of entity command runs for
* @param {boolean} continue Initial value is `true`. Event handler can set is ot `false` to prevent command execution
*/
$App.fireEvent('portal:mountTableEntity:before', mountTableEventParams)
if (!mountTableEventParams.continue) return // some of the event handlers may stop the process
const title = cfg.title || getEntityName(cfg)
const tableRender = h => {
const scopedSlots = cfg.scopedSlots && cfg.scopedSlots(h)
return h(UMasterDetailView, {
devtoolsTags: ['tableEntity', getEntityName(cfg)],
cfg,
attrs: {
...cfg.props,
shortcutCode: cfg.shortcutCode,
uiSettingKey: cfg.uiSettingKey || cfg.shortcutCode,
isModal: cfg.isModal
},
style: { height: '100%' },
scopedSlots
})
}
if (cfg.isModal) {
mountTableEntityAsModal({
title: UB.i18n(title),
tableRender
})
} else {
mountTableEntityAsTab({
title,
iconCls: cfg.iconCls,
commandConfig: cfg.commandConfig,
tabId: cfg.tabId,
uiTag: cfg.uiTag,
tableRender
})
}
}
/**
* Run UMasterDetailView as modal
*
* @param {object} cfg
* @param {string} cfg.title Modal title. Can contain macros `{attrName}`, such macros will be replaced by attributes values
* @param {Function} cfg.tableRender UMasterDetailView render function
* @param {string} [cfg.modalClass] Modal class
* @param {string} [cfg.modalWidth] Modal width
*/
function mountTableEntityAsModal ({
title,
tableRender,
modalClass = 'ub-dialog__reset-padding',
modalWidth
}) {
modalClass += ' ub-dialog__min-width ub-dialog__table-entity'
if (!modalWidth) {
modalClass += ' ub-dialog__max-width'
}
const instance = new Vue({
devtoolsTags: ['modal', 'tableEntity'],
cfg: {
title,
tableRender,
modalClass,
modalWidth
},
provide () {
return {
close: () => {
this.dialogVisible = false
}
}
},
data () {
return {
dialogVisible: false
}
},
destroyed () {
if (this.$el) {
document.body.removeChild(this.$el)
}
},
render (h) {
return h(
Dialog,
{
ref: 'dialog',
class: modalClass,
props: {
title,
visible: this.dialogVisible,
width: modalWidth,
closeOnClickModal: false
},
on: {
open: () => {
this.$nextTick(() => {
const { dialog } = this.$refs
const table = dialog.$el.querySelector('table')
if (table) table.focus()
})
},
closed: () => {
this.$destroy()
},
'update:visible': (val) => {
this.dialogVisible = val
}
}
},
[tableRender(h)]
)
}
})
instance.$mount()
document.body.appendChild(instance.$el)
instance.dialogVisible = true
return instance
}
/**
* Run UMasterDetailView as tab
*
* @param {object} cfg
* @param {string} cfg.title Tab title. Can contain macros `{attrName}`, such macros will be replaced by attributes values
* @param {string} cfg.tabId Navbar tab ID
* @param {string} [cfg.uiTag] UI Tag for tracking subsystem
* @param {Function} cfg.tableRender UMasterDetailView render function
* @param cfg.iconCls
* @param cfg.commandConfig
*/
function mountTableEntityAsTab ({
title,
tabId,
uiTag,
iconCls,
commandConfig,
tableRender
}) {
const existedTab = $App.viewport.centralPanel.getTabById(tabId)
if (existedTab) {
$App.viewport.centralPanel.setActiveTab(existedTab)
} else {
const items = $App.viewport.centralPanel.items
const tabsCnt = items.length
if (tabsCnt >= UB.connection.appConfig.uiSettings.adminUI.maxMainWindowTabOpened) {
$App.dialogError('tabsCountLimitExceeded')
return
}
const tab = $App.viewport.centralPanel.add({
commandConfig,
iconCls,
title: UB.i18n(title),
id: tabId,
closable: true,
uiTag,
renderFn: tableRender
})
$App.viewport.centralPanel.setActiveTab(tab)
}
}