/**
* @module Form
* @memberOf module:@unitybase/adminui-vue
* @author dmytro.rudnyk 31.05.2019
*/
/* global $App */
const Vue = require('vue')
const Vuex = require('vuex')
const { mountTab, mountModal, mountContainer } = require('./mount')
const createProcessingModule = require('./processing')
const {
mergeStore,
transformCollections,
enrichFieldList
} = require('./helpers')
const { Validator } = require('./validation')
const UB = require('@unitybase/ub-pub')
/**
* Creates a new instance of UI module what can be visualized in the application shell using `mount*()` call
* @param {object} cfg
* @param {Vue.Component} [cfg.component] Form component
* @param {Vue.Component} [cfg.rootComponent] Alias for `cfg.component`
* @param {object} [cfg.props] Form component props
* @param {object} [cfg.props.parentContext] Attributes values what will be passed to addNew method
* in case instanceID is empty. Think of it as default values for attributes of a new record
* @param {string} [cfg.title] Form title. Can contain macros `{attrName}`, such macros will be replaced by attributes values. Example - `({code}) {name}`
* @param {string} cfg.entity Entity name for master record
* @param {number} [cfg.instanceID] Instance ID
* @param {boolean} [cfg.isModal=false] If true form will be displayed inside modal dialog. Otherwise - in tab (default)
* @param {boolean} [cfg.openInBackgroundTab=false] If `true` - the tab with a newly opened form does not become active
* @param {string} [cfg.modalClass] Modal class
* @param {string} [cfg.modalWidth] Modal width
* @param {string} [cfg.formCode] Required to provide form code for form constructor button in toolbar and for correct tabID generation
* @param {string} [cfg.tabId] Optional tabId. If omitted will be calculated using entity code and instanceID
* @param {object} [cfg.target] Optional target. Used for render form into form
* @param {boolean} cfg.isCopy Required isCopy. Used for create new record with data of existing record
* @method
* @returns {UForm}
*/
module.exports.Form = function Form (cfg) {
return new UForm(cfg)
}
const formCaptionMixin = (entity, titleTemplate) => ({
watch: {
formTitle: 'updateFormTitle'
},
computed: {
formTitle () {
const entityName = this.$ut(entity + '#captionSingular')
if (this.$store.state.isNew) {
return entityName
}
const entityInfo = this.$UB.connection.domain.get(entity)
const captionMixin = entityInfo.mixins.caption
const template = titleTemplate || captionMixin?.template
// Parse arguments out of template
const re = /{(\w+(\.\w+)*)}/g
let reMatch
let value = template
while ((reMatch = re.exec(template)) !== null) {
const attr = reMatch[1]
const attrValue = this.$store.state.data[attr] || ''
value = value.replace('{' + attr + '}', attrValue)
}
// If trimmedValue is empty, return entityName without parentheses
const trimmedValue = value.trim()
return trimmedValue ? `${trimmedValue} (${entityName})` : entityName
}
},
mounted () {
this.updateFormTitle()
},
methods: {
updateFormTitle () {
if (typeof this.setTitle === 'function') {
this.setTitle(this.formTitle)
}
}
}
})
/**
* @.classdesc
* Creates a store for the form and renders it to the tab or modal window.
* Store track form changes and builds insert/delete requests.
* Class build validation and check it before save.
*/
class UForm {
/**
* @param {object} cfg
* @param {Vue.Component} [cfg.component] Form component
* @param {Vue.Component} [cfg.rootComponent] Alias for cfg.component
* @param {object} [cfg.props] Form component props
* @param {object} [cfg.props.parentContext] Attributes values that will be passed to addNew method
* in case instanceID is empty. Think of it as default values for attributes of a new record
* @param {string} [cfg.title] Form title
* @param {string} cfg.entity Entity name for master record
* @param {number} [cfg.instanceID] Instance ID
* @param {boolean} [cfg.isModal=false] If true form will be displayed inside modal dialog. Otherwise - in tab (default)
* @param {boolean} [cfg.openInBackgroundTab=false] If `true` - the tab with a newly opened form does not become active
* @param {string} [cfg.modalClass] Modal class
* @param {string} [cfg.modalWidth] Modal width
* @param {string} [cfg.formCode] Required to provide form code for form constructor button in toolbar and for correct tabID generation
* @param {string} [cfg.tabId] Optional tabId. If omitted will be calculated using entity code and instanceID
* @param {string} [cfg.uiTag] Optional UI Tag for tracking subsystem
* @param {object} [cfg.target] Optional target. Used for render form into form
* @param {boolean} cfg.isCopy Required isCopy. Used for create new record with data of existing record
* @param {string} [cfg.titleTooltip] Form title tooltip
* @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
*/
constructor ({
component,
rootComponent,
props,
title,
entity,
instanceID,
isModal,
modalClass,
modalWidth,
formCode,
tabId,
uiTag,
target,
isCopy,
openInBackgroundTab,
titleTooltip,
onClose
}) {
this.component = component || rootComponent
this.props = props
this.mixins = undefined
this.storeConfig = {}
this.$store = undefined
this.entity = entity
this.uiTag = uiTag
if (this.entity && UB.connection.domain.has(this.entity)) {
this.entitySchema = UB.connection.domain.get(this.entity)
this.title = title || this.entitySchema.captionSingular || this.entitySchema.getEntityCaption()
this.fieldList = this.entitySchema.getAttributeNames()
} else {
this.entitySchema = null
this.title = title
this.fieldList = []
}
this.titleTooltip = titleTooltip || this.title
this.instanceID = instanceID
this.formCode = formCode
this.isCopy = isCopy
this.collections = {}
this.target = target
this.tabId = tabId
this.isModal = isModal
this.openInBackgroundTab = openInBackgroundTab
this.modalClass = modalClass
this.modalWidth = modalWidth
this.validator = undefined
this.customValidationOptions = undefined
this.isProcessingUsed = false
this.isValidationUsed = false
this.storeInitialized = false
this.canValidationInit = false
this.onClose = onClose
}
/**
*
* @param {Vuex.StoreOptions} [storeConfig={}] Vuex store constructor options - merged with UForm store
* @return {UForm}
*/
store (storeConfig = {}) {
if (this.storeInitialized) {
throw new Error('Store is already initialized. TIP: ".store()" should be called before ".processing()"')
}
this.storeInitialized = true
mergeStore(this.storeConfig, storeConfig)
return this
}
/**
* @deprecated replaced to processing
* @private
*/
instance () {
return this
}
/**
* Sets store field list, collections and lifecycle hooks.
* All hooks are called with one argument $store and this === current form.
*
* @param {Object} [cfg]
* @param {string[]} [cfg.masterFieldList] form field list. By default all entity attributes
* @param {Object} [cfg.collections]
* @param {function} [cfg.beforeInit]
* @param {function} [cfg.inited]
* @param {function} [cfg.beforeSave] Called before form creates insert\update request.
* If returned value is resolved to `false` then save action is canceled
* @param {function} [cfg.saved]
* @param {function} [cfg.beforeCreate]
* @param {function} [cfg.created]
* @param {function} [cfg.beforeLoad]
* @param {function} [cfg.loaded]
* @param {function} [cfg.beforeDelete]
* @param {function} [cfg.deleted]
* @param {function} [cfg.beforeCopy]
* @param {function} [cfg.copied]
* @param {function} [saveNotification] Callback that overrides the default save notification
* @param {function} [errorNotification] Callback that overrides the default error notification
* @param {function} [transformSaveRequest] Function that can manipulate requests before sending to server: order, patch, etc.
* @returns {UForm}
*/
processing ({
masterFieldList,
collections = {},
beforeInit,
inited,
beforeSave,
saved,
beforeCreate,
created,
beforeLoad,
loaded,
beforeDelete,
deleted,
beforeCopy,
copied,
saveNotification,
errorNotification,
transformSaveRequest
} = {}) {
this.storeInitialized = true
this.canValidationInit = true
this.isProcessingUsed = true
this.eventBus = new Vue()
if (masterFieldList) {
this.fieldList = enrichFieldList(this.entitySchema, masterFieldList, ['ID', 'mi_modifyDate', 'mi_createDate'])
}
this.collections = collections
transformCollections(this.collections)
const processingModule = createProcessingModule({
entity: this.entity,
entitySchema: this.entitySchema,
fieldList: this.fieldList,
instanceID: this.instanceID,
parentContext: (this.props && this.props.parentContext) ? this.props.parentContext : undefined,
collections,
validator: () => this.validator,
beforeInit: beforeInit ? () => beforeInit.call(this, this.$store) : null,
inited: inited ? () => inited.call(this, this.$store) : null,
beforeSave: beforeSave ? () => beforeSave.call(this, this.$store) : null,
saved: saved ? (method) => saved.call(this, this.$store, method) : null,
beforeCreate: beforeCreate ? () => beforeCreate.call(this, this.$store) : null,
created: () => {
this.tabId = UB.core.UBApp.generateTabId({
entity: this.entity,
instanceID: this.$store.state.data.ID,
formCode: this.formCode ?? this.entity
})
return created ? created.call(this, this.$store) : null
},
beforeLoad: beforeLoad ? () => beforeLoad.call(this, this.$store) : null,
loaded: loaded ? () => loaded.call(this, this.$store) : null,
beforeDelete: beforeDelete ? () => beforeDelete.call(this, this.$store) : null,
deleted: deleted ? () => deleted.call(this, this.$store) : null,
beforeCopy: beforeCopy ? () => beforeCopy.call(this, this.$store) : null,
copied: copied ? (...args) => copied.call(this, this.$store, ...args) : null,
afterValidation: () => {
const promises = []
this.eventBus.$emit('afterValidation', promises)
return Promise.all(promises)
},
saveNotification,
errorNotification,
transformSaveRequest,
isCopy: this.isCopy,
isModal: this.isModal
})
mergeStore(this.storeConfig, processingModule)
return this
}
/**
* Custom validator function. In case `validation` not called or called without argument then validator function
* is generated automatically based on entitySchema
* @param {Vue.ComponentOptions} [validationOptions] Custom validation mixin in case we need to override default validation
* @return {UForm}
*/
validation (validationOptions) {
if (validationOptions && (typeof validationOptions === 'function')) {
throw new Error('Invalid parameter type for UForm.validation - must be object with at last computed or validation props')
}
if (!this.canValidationInit) {
throw new Error('You can use ".validation()" only after ".processing()" and before ".mount()". Or ".validation()" is already initialized')
}
this.canValidationInit = false
this.isValidationUsed = true
if (validationOptions) {
this.customValidationOptions = validationOptions
}
return this
}
async mount () {
if (this.storeInitialized) {
this.$store = new Vuex.Store(this.storeConfig)
}
if (this.isProcessingUsed && this.entitySchema.hasMixin('softLock')) {
this.$store.watch(
(state, getters) => getters.isDirty,
(dirty) => this.lockUnlockOnDirtyChanged(dirty)
)
}
if (this.isValidationUsed) {
this.validator = Validator.initializeWithCustomOptions({
store: this.$store,
entitySchema: this.entitySchema,
masterFieldList: this.fieldList,
customValidationMixin: this.customValidationOptions
})
}
if (this.isProcessingUsed) {
try {
await this.$store.dispatch('init')
} catch (err) {
throw new UB.UBError(err.message)
}
}
if (this.title && this.title.indexOf('{') !== -1) {
if (!this.mixins) {
this.mixins = []
}
this.mixins.push(formCaptionMixin(this.entity, this.title))
}
const mountOptions = {
component: this.component,
props: this.props,
mixins: this.mixins,
store: this.$store,
validator: this.validator,
title: this.title,
titleTooltip: this.titleTooltip,
onClose: this.onClose,
provide: {
formCode: this.formCode,
entity: this.entity,
entitySchema: this.entitySchema,
fieldList: this.fieldList,
formEvents: this.eventBus
}
}
if (this.isModal) {
mountModal({
...mountOptions,
modalClass: this.modalClass,
modalWidth: this.modalWidth
})
} else if (this.target === undefined || (this.target.getId && this.target.getId() === 'ubCenterViewport')) {
if (!this.tabId) {
this.tabId = this.entity
? $App.generateTabId({ // TODO portal: $App.generateTabId -> portal.generateTabId
entity: this.entity,
instanceID: this.instanceID,
formCode: this.formCode
})
: undefined
}
const existsTab = document.getElementById(this.tabId)
if (existsTab) return // for slow network
mountTab({
...mountOptions,
tabId: this.tabId,
uiTag: this.uiTag,
openInBackgroundTab: this.openInBackgroundTab
})
} else {
mountContainer({
...mountOptions,
target: this.target
})
}
}
/**
* Applicable for entities with softLock mixin.
* - if dirty === true and entity is not already locked try to get a Temp lock
* - if dirty === false and entity is locked by current user using Temp lock - unlock it
* @private
* @param {boolean} dirty
*/
lockUnlockOnDirtyChanged (dirty) {
// console.log('DIRTY', dirty)
if (this.$store && this.$store.state.isNew) return
if (dirty) {
if (!this.$store.getters.isLockedByMe) {
this.$store.dispatch('lockEntity')
}
} else {
if ((this.$store.getters.isLockedByMe) && (this.$store.state.lockInfo.lockType === 'Temp')) {
this.$store.dispatch('unlockEntity')
}
}
}
}