/* global _ */
/**
* Helpers for Forms. Exported by `@unitybase/adminui-vue` as `formHelpers` and can be used as
*
* @example
// valid usage
const formHelpers = require('@unitybase/adminui-vue').formHelpers
// WRONG usage
const helpers = require('@unitybase/adminui-vue/utils/Form/helpers')
* @module formHelpers
* @memberOf module:@unitybase/adminui-vue
*/
module.exports = {
buildExecParams,
mapInstanceFields,
computedVuex,
mergeStore,
required,
transformCollections,
buildDeleteRequest,
enrichFieldList,
SET,
isEmpty,
change,
markAsTouched,
prepareCopyAddNewExecParams,
validateWithErrorText,
showRecordHistory
}
const UB = require('@unitybase/ub-pub')
const UB_DATA_TYPES = require('@unitybase/cs-shared').UBDomain.ubDataTypes
/** @type VueConstructor */
const Vue = require('vue')
// eslint-disable-next-line no-unused-vars
const Vuex = require('vuex') // required to see a Vuex.d.ts
const { withParams } = require('vuelidate/lib/params')
/**
* @typedef {object} VuexTrackedInstance
* @property {boolean} isNew Whether master instance was loaded or it is newly created
* @property {object} data Master record instance, current values, as shall be shown on UI
* @property {object} originalData Shadow copy of modified attributes
* @property {object<string, VuexTrackedCollection>} collections List of tracked detain collections
*/
/**
* @typedef {object} VuexTrackedCollection
* @property {string} entity Entity code
* @property {string} key Unique collection identifier
* @property {Array<VuexTrackedObject>} items Current items, as it shall be shown on UI
* @property {Array<VuexTrackedObject>} deleted Deleted items, except items which are added
* (not originally loaded)
* @property {string} key Custom unique key which is set on init collection
* @property {string} entity Entity code
*/
/**
* @typedef {object} VuexTrackedObject
* @property {boolean} isNew Indicator of whether master instance was loaded or it is newly created
* @property {object} data Master record instance, current values, as shall be shown on UI
* @property {object} originalData Shadow copy of modified attributes
*/
/**
* Check arg1 is strict equal to srg2, can compare primitive values, arrays (deep equal) or Date's. In addition:
* - [] and undefined is equal
* - {} and undefined is equal
*
* @param {*} arg1
* @param {*} arg2
*/
function isEqual (arg1, arg2) {
if (Array.isArray(arg1) || Array.isArray(arg2)) {
if (arg1 === undefined) {
arg1 = []
}
if (arg2 === undefined) {
arg2 = []
}
if (!Array.isArray(arg1) || !Array.isArray(arg2) || (arg1.length !== arg2.length)) {
return false
}
return _.isEqual(arg1, arg2) // deep array comparison
}
if (isDate(arg1) || isDate(arg2)) {
if (!isDate(arg1) || !isDate(arg2)) {
return false
}
return arg1.valueOf() === arg2.valueOf()
}
if (isObject(arg1) || isObject(arg2)) {
if (arg1 === undefined) {
arg1 = {}
}
if (arg2 === undefined) {
arg2 = {}
}
return _.isEqual(arg1, arg2)
}
return arg1 === arg2
}
/**
* Check if value is a Date
* @param {*} value
* @return {boolean}
*/
function isDate (value) {
return value instanceof Date && !isNaN(value)
}
/**
* Check if value is an object and not `null`
* @param value
*/
function isObject (value) {
return (typeof value === 'object') && (value !== null)
}
/**
* Check obj is empty (`null` or `{}`)
* @param {*} obj
* @return {Boolean}
*/
function isEmpty (obj) {
if (obj === null) return true
return (typeof obj === 'object') && (Object.keys(obj).length === 0)
}
/**
* A helper method to update the "tracked" object property
*
* @param {VuexTrackedObject} state
* @param {string} key
* @param {*} value
* @param {string} [path]
* Path could be deep path. Using deep path is only allowed to change or set leaf values,
* it won't recursively create objects along the path.
*/
function change (state, key, value, path) {
let currentValue = state.data[key]
if (path !== undefined) {
currentValue = _.get(currentValue, path)
}
if (isEqual(currentValue, value)) {
return
}
if (!(key in state.originalData)) {
// No value in "originalData" - edited for the first time, so save old value to "originalData"
// TODO: for object types, need to create clone
Vue.set(state.originalData, key, _.cloneDeep(state.data[key]))
}
if (path === undefined) {
Vue.set(state.data, key, value)
} else {
if (typeof state.data[key] !== 'object' || state.data[key] === null) {
// Create an object, if current value is not a valid object
Vue.set(state.data, key, {})
}
// If json path is deep (like 'accounts[0].fullFIO.middleName') -
// we need pass to Vue.set separated target object (state.data[key].accounts[0].fullFIO) and last propertyName (middleName).
// To supporting bracket notation, brackets replaces to dot.
// accounts[0].fullFIO.middleName -> accounts.0.fullFIO.middleName -> ['accounts', '0', 'fullFIO', 'middleName] ->
// _.get(state.data[key], ['accounts', '0', 'fullFIO'])
// This code works with plain path too (split return [path] if no '.').
// **Disadvantages of implementation**:
// 1. Bracket notation for string properties especially with '.' (accounts["prop.a"]) - not supported
// 2. Client need care yourself of existence target part of path (state.data[key].accounts[0].fullFIO).
// This implementation don't create not existed parts of path (like _.set() do)
const parts = path.replace(/]/g, '').replace(/\[/g, '.').split('.')
path = parts.pop()
const jsonAttr = parts.length === 0 ? state.data[key] : _.get(state.data[key], parts)
if (typeof jsonAttr === 'object' && jsonAttr !== null) {
if (value !== undefined) {
Vue.set(jsonAttr, path, value)
} else {
Vue.delete(jsonAttr, path)
}
}
}
if (isEqual(state.originalData[key], state.data[key])) {
// After and only after setting value, check if we got the same value as in originalData
// If set value to its original value, means reverting any changes made, so delete it from "originalData"
Vue.delete(state.originalData, key)
}
}
/**
* Mark field as touched (without changing value)
* If field is not in originalData - save current value to originalData
*
* @param {VuexTrackedObject} state
* @param {string} key
*/
function markAsTouched (state, key) {
if (!(key in state.originalData)) {
Vue.set(state.originalData, key, _.cloneDeep(state.data[key]))
}
}
/**
* "execParams" and "fieldList"
*
* @typedef {object} UbQueryParams
* @property {object} execParams
* @property {array} fieldList
*/
/**
* Build "execParams" out of the state tracked by "instance" module.
*
* @param {VuexTrackedObject} trackedObj
* @param {string} entity
* @return {object|null}
*/
function buildExecParams (trackedObj, entity) {
const execParams = {}
const schema = UB.connection.domain.get(entity)
if (trackedObj.isNew) {
for (const [key, value] of Object.entries(trackedObj.data)) {
const attr = schema.attributes[key]
if (!(attr && attr.readOnly) && !key.includes('.')) {
if (attr && attr.dataType === UB_DATA_TYPES.Date) {
execParams[key] = UB.truncTimeToUtcNull(trackedObj.data[key])
} else {
execParams[key] = value
}
}
}
if (schema.hasMixin('dataHistory')) {
// Let server fill historical attributes
for (const f of ['mi_data_id', 'mi_dateFrom', 'mi_dateTo']) {
if (!execParams[f]) {
delete execParams[f]
}
}
}
replaceMultilangParams(execParams)
return execParams
}
if (!Object.keys(trackedObj.originalData).length) {
return null
}
execParams.ID = trackedObj.data.ID
if (schema.attributes.mi_modifyDate) {
execParams.mi_modifyDate = trackedObj.data.mi_modifyDate
}
for (const key of Object.keys(trackedObj.originalData)) {
if (!key.includes('.')) {
const attr = schema.attributes[key]
if (trackedObj.data[key] && attr && attr.dataType === UB_DATA_TYPES.Date) {
execParams[key] = UB.truncTimeToUtcNull(trackedObj.data[key])
} else {
execParams[key] = trackedObj.data[key]
}
}
}
replaceMultilangParams(execParams)
return execParams
}
function buildDeleteRequest (entity, ID) {
return {
entity,
method: 'delete',
execParams: {
ID
}
}
}
/**
* Create an object with getter and setter for each of passed stateDataProps from vuex store state.data[propName]
*
* Setter perform a validation (if property is a subject for validation in $v - see {@link UForm.validation}) and
* calls `SET_DATA` store mutation.
*
* @param {string[]} stateDataProps array of store state.data property names to create a getter/setter for
* @param {string} [submoduleName] optional submodule name of store state
*/
function mapInstanceFields (stateDataProps, submoduleName) {
if (!Array.isArray(stateDataProps)) throw new Error('First argument for mapInstanceFields must be array of string')
const obj = {}
for (const key of stateDataProps) {
obj[key] = {
get () {
if (submoduleName) {
return this.$store.state[submoduleName].data[key]
} else {
return this.$store.state.data[key]
}
},
set (value) {
if (this.$v && key in this.$v) {
this.$v[key].$touch()
}
if (submoduleName) {
this.$store.commit(`${submoduleName}/SET_DATA`, { key, value })
} else {
this.$store.commit('SET_DATA', { key, value })
}
}
}
}
return obj
}
/**
* Create an object with getter and setter for each of passed stateProp from vuex store state.
* Setter calls a SET mutation what should be implemented in store (imported from helpers for example).
*
* @param {string[]} stateProps array of store state property names to create a getter/setter for
* @param {string} [submoduleName] optional submodule name of store state
*/
function computedVuex (stateProps, submoduleName) {
if (!Array.isArray(stateProps)) throw new Error('First argument for computedVuex must be array of string')
const obj = {}
const SET_CMD = submoduleName ? submoduleName + '/SET' : 'SET'
for (const prop of stateProps) {
obj[prop] = {
get () {
return submoduleName ? this.$store.state[submoduleName][prop] : this.$store.state[prop]
},
set (value) {
this.$store.commit(SET_CMD, { key: prop, value })
}
}
}
return obj
}
/**
* Assign source store options into target store options
* @param {Vuex.StoreOptions} target Target store
* @param {Vuex.StoreOptions} source Source store
*/
function mergeStore (target, source) {
const sourceState = typeof source.state === 'function'
? source.state()
: source.state
function assignWith (key) {
target[key] = Object.assign({}, source[key], target[key])
}
target.state = Object.assign({}, sourceState, target.state)
assignWith('getters')
assignWith('mutations')
assignWith('actions')
assignWith('modules')
// merge plugins
if (source.plugins) {
if (!target.plugins) {
target.plugins = []
}
target.plugins.push(...source.plugins)
}
// merge strict mode
if (source.strict !== undefined) {
target.strict = source.strict
}
}
/**
* throw error on missing required prop of func
* @param param
*/
function required (param) {
throw new Error(`Parameter "${param}" is required`)
}
/**
* Transform's each collection object to
* `key: {
* repository: store => UB.Repository(),
* lazy: true/false
* }`
*
* @param {Object<string, UbVuexStoreCollectionInfo|UbVuexStoreRepositoryBuilder>} collections
* @return {void}
*/
function transformCollections (collections) {
for (let [key, collectionInfo] of Object.entries(collections)) {
// Replace shorthand syntax, when collection is defined by repository to full collection info object
if (isRepository(collectionInfo) || typeof collectionInfo === 'function') {
collectionInfo = collections[key] = {
repository: collectionInfo,
lazy: false
}
}
// Replace ClientRepository with a factory function, and output a warning for developers
if (isRepository(collectionInfo.repository)) {
if (window.isDeveloperMode) {
console.warn(
'Use factory function for building collection requests, not ready Repository objects! collection: %s, entity',
key, collectionInfo.repository.entityName
)
}
const repositoryInstance = collectionInfo.repository
collectionInfo.repository = () => repositoryInstance
}
if (typeof collectionInfo.repository !== 'function') {
throw new UB.UBError(`Can't find ClientRepository in "${key}" collection`)
}
collectionInfo.lazy = collectionInfo.lazy === true
}
}
function isRepository (obj) {
return obj instanceof UB.ClientRepository
}
/**
* This mutation is needed in order to reuse it in the store modules,
* since computedVuex will not work in the store module without such a mutation
* Set base state values
* @param {VuexTrackedInstance} state
* @param {object} payload
* @param {String} payload.key state key
* @param {*} payload.value value
*/
function SET (state, { key, value }) {
state[key] = value
}
/**
* @param {UBEntity} entitySchema
* @param {string[]} fieldList
* @param {string[]} requiredAttrs
* @return {string[]}
*/
function enrichFieldList (entitySchema, fieldList, requiredAttrs) {
const fieldsToAppend = requiredAttrs.filter(attr => fieldList.indexOf(attr) === -1 && entitySchema.attributes[attr])
return fieldList.concat(fieldsToAppend)
}
const LANG_PARAM_RE = /(\S+)_\S+\^/
/**
* If execParams includes locale params
* will replace the locale param with base param.
*
* For example in case userLang === 'en'
* and execParams includes key 'name_uk^'
* will replace key 'name' to 'name_en^'
*
* @param {object} execParams
*/
function replaceMultilangParams (execParams) {
const langParams = Object.keys(execParams)
.filter(a => a.includes('^'))
const userLang = UB.connection.userLang()
for (const p of langParams) {
const res = p.match(LANG_PARAM_RE)
const key = res && res[1]
if (key in execParams) {
const localeKey = key + '_' + userLang + '^'
execParams[localeKey] = execParams[key]
delete execParams[key]
}
}
}
/**
* @param {object} originalExecParams
* @param {string} entity
* @returns {object} execParams
*/
function prepareCopyAddNewExecParams (originalExecParams, entity) {
const execParams = { ...originalExecParams }
// exclude ID
delete execParams.ID
// convert Json fields into string
for (const attrCode of Object.keys(execParams)) {
const attr = UB.connection.domain.get(entity).attr(attrCode, true)
if (attr?.dataType === UB.connection.domain.ubDataTypes.Json) {
execParams[attrCode] = JSON.stringify(execParams[attrCode])
}
}
return execParams
}
/**
* Assign some error text for validator function.
* @param {string} errorLocale
* @param {function(*):boolean} validator
* @returns {function(*):boolean}
*/
function validateWithErrorText (errorLocale, validator) {
return withParams({ $errorText: errorLocale }, validator)
}
/**
* Show Ext-js based form with changes history of specified instance (for entity with `dataHistory` mixin)
* @param {string} entityName
* @param {number} instanceID
* @param {array<string>} fieldList
* @param {array<object>} [columns] optional columns definition for showList
* @returns {Promise<void>}
*/
function showRecordHistoryExtForm (entityName, instanceID, fieldListOrig, columns) {
const fieldList = new Set(fieldListOrig)
fieldList.add('mi_dateFrom')
fieldList.add('mi_dateTo')
const extendedFieldList = UB.core.UBUtil.convertFieldListToExtended([...fieldList])
for (const item of extendedFieldList) {
const field = item.name
if (field === 'mi_dateTo' || field === 'mi_dateFrom') {
item.visibility = true
item.description = UB.i18n(field)
}
}
return $App.doCommand({
cmdType: 'showList',
renderer: 'ext',
isModal: true,
cmdData: {
params: [{
entity: entityName,
method: 'select',
fieldList: [...fieldList]
}]
},
cmpInitConfig: {
extendedFieldList
},
instanceID: instanceID,
__mip_recordhistory: true
})
}
/**
* Show Vue based form with changes history of specified instance (for entity with `dataHistory` mixin)
* @param {string} entityName
* @param {number} instanceID
* @param {array<string>} fieldList
* @param {array<object>} [columns] optional columns definition for showList
* @returns {Promise<void>}
*/
async function showRecordHistory (entityName, instanceID, fieldList, columns) {
const useVueTables = UB.connection.appConfig.uiSettings.adminUI.useVueTables
if (!useVueTables) {
// TODO: delete this code after complete migration to vue, or when ext form will not be needed
return showRecordHistoryExtForm(entityName, instanceID, fieldList, columns)
}
const dataId = await UB.Repository(entityName)
.attrs('mi_data_id')
.where('ID', '=', instanceID)
.misc({ __mip_disablecache: true })
.selectScalar()
const newFieldList = [...fieldList]
const newColumns = columns ? [...columns] : [...fieldList]
;['mi_dateFrom', 'mi_dateTo'].forEach(cn => {
if (newFieldList.indexOf(cn) === -1) newFieldList.push(cn)
if (newColumns.findIndex(c => (typeof c === 'string' && c === cn) || (typeof c === 'object' && c.id === cn)) === -1) {
newColumns.push(cn)
}
})
return UB.core.UBApp.doCommand({
cmdType: 'showList',
renderer: 'vue',
isModal: true,
cmdData: {
entityName: entityName,
repository: () => UB.Repository(entityName)
.attrs([...newFieldList])
.where('mi_data_id', '=', dataId)
.misc({ __mip_disablecache: true, __mip_recordhistory_all: true }),
columns: newColumns
}
})
}