diff --git a/core/app/common/src/lib/common.ts b/core/app/common/src/lib/common.ts index 8461d2c789..7c6c89e7c1 100644 --- a/core/app/common/src/lib/common.ts +++ b/core/app/common/src/lib/common.ts @@ -17,6 +17,7 @@ export * from './metadata/widget.metadata'; export * from './record/favorites.model'; export * from './record/field.model'; export * from './record/recently-viewed.model'; +export * from './record/record-logic-action.model'; export * from './record/record.model'; export * from './record/record-mappers/record-mapper.model'; export * from './record/record-mappers/record-mapper.registry'; diff --git a/core/app/common/src/lib/record/record-logic-action.model.ts b/core/app/common/src/lib/record/record-logic-action.model.ts new file mode 100755 index 0000000000..9f1f4c06da --- /dev/null +++ b/core/app/common/src/lib/record/record-logic-action.model.ts @@ -0,0 +1,41 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2023 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ +import { Action } from '../actions/action.model' +import { AttributeDependency } from './field.model' + + +export interface RecordLogicMap { + [recordLogicName: string]: RecordLogic; +} + +export interface RecordLogic extends Action { + params?: { + fieldDependencies?: string[]; + attributeDependencies?: AttributeDependency[]; + triggerOnEvents?: [LineActionEvent: boolean][]; + [key: string]: any; + }; +} diff --git a/core/app/core/src/lib/core.ts b/core/app/core/src/lib/core.ts index ab8b035733..1945b6e481 100644 --- a/core/app/core/src/lib/core.ts +++ b/core/app/core/src/lib/core.ts @@ -453,6 +453,7 @@ export * from './services/formatters/number/number-formatter.service'; export * from './services/formatters/phone/phone-formatter.service'; export * from './services/language/dynamic-label.service'; export * from './services/local-storage/local-storage.service'; +export * from './services/logic/active-logic-checker.service'; export * from './services/message/message.service'; export * from './services/metadata/base-metadata.resolver'; export * from './services/metadata/base-module.resolver'; @@ -625,5 +626,8 @@ export * from './views/record/components/record-header/record-header.component'; export * from './views/record/components/record-header/record-header.module'; export * from './views/record/components/record-view/record.component'; export * from './views/record/components/record-view/record.module'; +export * from './views/record/record-logic/record-logic.action'; +export * from './views/record/record-logic/record-logic.manager'; +export * from './views/record/record-logic/record-logic.model'; export * from './views/record/store/record-view/record-view.store.model'; export * from './views/record/store/record-view/record-view.store'; diff --git a/core/app/core/src/lib/services/logic/active-logic-checker.service.ts b/core/app/core/src/lib/services/logic/active-logic-checker.service.ts new file mode 100644 index 0000000000..055225dca4 --- /dev/null +++ b/core/app/core/src/lib/services/logic/active-logic-checker.service.ts @@ -0,0 +1,184 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2023 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ +import { isArray, isEmpty } from 'lodash-es'; +import { Injectable } from '@angular/core'; +import { + Record, + Action, + Field, + StringArrayMap, + StringArrayMatrix, + ViewMode, + FieldAttribute, + ALL_VIEW_MODES, + LogicRuleValues, +} from 'common'; +import { ConditionOperatorManager } from '../condition-operators/condition-operator.manager'; + +@Injectable({ + providedIn: 'root', +}) +export class ActiveLogicChecker { + + modes: ViewMode[] = ALL_VIEW_MODES; + + constructor( + protected operatorManager: ConditionOperatorManager + ) { + } + + public run(record: Record, action: Action): boolean { + if (!record || !action) { + return true; + } + + const activeOnFields: StringArrayMap = action.params?.activeOnFields || {}; + + const activeOnAttributes: StringArrayMatrix = action.params?.activeOnAttributes || {}; + + return this.isActive(record, activeOnFields, activeOnAttributes); + } + + /** + * Check if any of the configured values is currently set + * + * @param {Record} record Record + * @param {StringArrayMap} activeOnFields Active On Fields + * @param {StringArrayMatrix} activeOnAttributes Active On Attributes + * @returns {boolean} true if any of the configured values is currently set + */ + protected isActive( + record: Record, + activeOnFields: StringArrayMap, + activeOnAttributes: StringArrayMatrix + ): boolean { + let isActive = true; + if (!isEmpty(activeOnFields)) { + isActive = isActive && this.areFieldsActive(record, activeOnFields); + } + if (!isEmpty(activeOnAttributes)) { + isActive = isActive && this.areAttributesActive(record, activeOnAttributes); + } + + return isActive; + } + + /** + * Are fields active + * + * @param {Record} record Record + * @param {StringArrayMap} activeOnFields StringArrayMap + * @returns {boolean} true are fields active + */ + protected areFieldsActive(record: Record, activeOnFields: StringArrayMap): boolean { + let areActive = true; + + Object.entries(activeOnFields).forEach(([fieldKey, activeValues]) => { + if (!areActive) { + return; + } + + const field = (record.fields ?? {})[fieldKey] ?? null; + if (!field || isEmpty(activeValues)) { + return; + } + + areActive = this.isValueActive(record, field, activeValues); + }); + + return areActive; + } + + /** + * Are attributes active + * + * @param {Record} record Record + * @param {StringArrayMatrix} activeOnAttributes Active On Attributes + * @returns {boolean} true if are attributes active + */ + protected areAttributesActive(record: Record, activeOnAttributes: StringArrayMatrix): boolean { + let areActive = true; + + Object.entries(activeOnAttributes).forEach(([fieldKey, attributesMap]) => { + if (!areActive) { + return; + } + + const field = (record.fields ?? {})[fieldKey] ?? null; + if (!field || isEmpty(attributesMap)) { + return; + } + + Object.entries(attributesMap).forEach(([attributeKey, activeValues]) => { + if (!areActive) { + return; + } + + const attribute = (field.attributes ?? {})[attributeKey] ?? null; + if (!attribute || isEmpty(activeValues)) { + return; + } + + areActive = this.isValueActive(record, attribute, activeValues); + }); + }); + + return areActive; + } + + /** + * Is value active + * + * @param {Record} record Record + * @param {Field | FieldAttribute} value Value + * @param {Array | any} activeValueOrValues Active Value Or Values + * @returns {boolean} true if is value active + */ + protected isValueActive( + record: Record, + value: Field | FieldAttribute, + activeValueOrValues: string | LogicRuleValues | Array + ): boolean { + const activeValues = isArray(activeValueOrValues) ? activeValueOrValues : [activeValueOrValues]; + + const toCompareValueList = !isEmpty(value.valueList) + ? value.valueList + : [value.value]; + + return activeValues.some(activeValue => { + if (typeof activeValue === 'string') { + return toCompareValueList.some(toCompareValue => activeValue === toCompareValue); + } + + const operatorKey = activeValue.operator ?? ''; + const operator = this.operatorManager.get(operatorKey); + if (!operator) { + console.warn(`ActiveLogicChecker.isValueActive: Operator: '${operatorKey}' not found.`); + } + return operator?.run(record, value, activeValue); + }); + } +} diff --git a/core/app/core/src/lib/services/process/processes/async-action/async-action.ts b/core/app/core/src/lib/services/process/processes/async-action/async-action.ts index 254a82e799..7da50a3fb9 100644 --- a/core/app/core/src/lib/services/process/processes/async-action/async-action.ts +++ b/core/app/core/src/lib/services/process/processes/async-action/async-action.ts @@ -48,7 +48,7 @@ export interface AsyncActionInput { modalRecord?: Record; record?: Record; - [key: string]: any + [key: string]: any; } @Injectable({ @@ -81,11 +81,17 @@ export class AsyncActionService { * Send action request * * @param {string} actionName to submit - * @param {string} data to send + * @param {object} data to send * @param {string} presetHandlerKey to use - * @returns {object} Observable + * @param {string[]} parentHandlers Parent Handlers + * @returns {Observable} Observable */ - public run(actionName: string, data: AsyncActionInput, presetHandlerKey: string = null): Observable { + public run( + actionName: string, + data: AsyncActionInput, + presetHandlerKey: string = null, + parentHandlers: string[] = [] + ): Observable { const options = { ...data }; @@ -106,7 +112,7 @@ export class AsyncActionService { if (process.messages) { process.messages.forEach(message => { - if(!!message) { + if(message) { this.message[handler](message); } }); @@ -124,6 +130,10 @@ export class AsyncActionService { const actionHandler: AsyncActionHandler = this.actions[actionHandlerKey]; + if (parentHandlers.includes(actionHandlerKey)) { + return; + } + if (!actionHandler) { this.message.addDangerMessageByKey('LBL_MISSING_HANDLER'); return; @@ -133,7 +143,7 @@ export class AsyncActionService { }), catchError(err => { - const errorMessage = err?.message ?? '' + const errorMessage = err?.message ?? ''; if (errorMessage === 'Access Denied.') { this.appStateStore.updateLoading(actionName, false); diff --git a/core/app/core/src/lib/store/metadata/metadata.store.service.ts b/core/app/core/src/lib/store/metadata/metadata.store.service.ts index 4ca2a6ffd8..65c19b9292 100644 --- a/core/app/core/src/lib/store/metadata/metadata.store.service.ts +++ b/core/app/core/src/lib/store/metadata/metadata.store.service.ts @@ -41,7 +41,8 @@ import { SearchMeta, SubPanelMeta, WidgetMetadata, - TabDefinitions + TabDefinitions, + RecordLogicMap } from 'common'; import {StateStore} from '../state'; import {AppStateStore} from '../app-state/app-state.store'; @@ -55,6 +56,7 @@ export interface RecordViewMetadata { sidebarWidgets?: WidgetMetadata[]; bottomWidgets?: WidgetMetadata[]; actions?: Action[]; + logic?: RecordLogicMap; templateMeta?: RecordTemplateMetadata; panels?: Panel[]; summaryTemplates?: SummaryTemplates; @@ -222,10 +224,10 @@ export class MetadataStore implements StateStore { * * @param {string} moduleID to fetch * @param {string[]} types to fetch - * @param useCache - * @returns any data + * @param {boolean} useCache Use Cache + * @returns {any} any data */ - public reloadModuleMetadata(moduleID: string, types: string[], useCache: boolean = true): any { + public reloadModuleMetadata(moduleID: string, types: string[], useCache = true): any { if (!types) { types = this.getMetadataTypes(); @@ -243,10 +245,10 @@ export class MetadataStore implements StateStore { * * @param {string} moduleID to fetch * @param {string[]} types to fetch - * @param useCache - * @returns any data + * @param {boolean} useCache Use Cache + * @returns {any} any data */ - public load(moduleID: string, types: string[], useCache: boolean = true): any { + public load(moduleID: string, types: string[], useCache = true): any { if (!types) { types = this.getMetadataTypes(); @@ -261,6 +263,9 @@ export class MetadataStore implements StateStore { /** * Check if loaded + * + * @param {string} module Module + * @returns {boolean} Is Cached? */ public isCached(module: string): boolean { return (cache[module] ?? null) !== null; @@ -268,13 +273,18 @@ export class MetadataStore implements StateStore { /** * Get empty Metadata + * + * @returns {object} Metadata */ public getEmpty(): Metadata { return deepClone(initialState); } /** - * Set pre-loaded navigation and cache + * Set preloaded navigation and cache + * + * @param {string} module Module + * @param {object} metadata Metadata */ public set(module: string, metadata: Metadata): void { cache[module] = of(metadata).pipe(shareReplay(1)); @@ -286,10 +296,10 @@ export class MetadataStore implements StateStore { * * @param {string} module to fetch * @param {string[]} types to retrieve - * @param useCache + * @param {boolean} useCache Use Cache * @returns {object} Observable */ - public getMetadata(module: string, types: string[] = null, useCache: boolean = true): Observable { + public getMetadata(module: string, types: string[] = null, useCache = true): Observable { if (cache[module] == null || useCache === false) { cache[module] = this.fetchMetadata(module, types).pipe( @@ -320,7 +330,7 @@ export class MetadataStore implements StateStore { /** * Update the state * - * @param {string} module + * @param {string} module Module * @param {object} state to set */ protected updateState(module: string, state: Metadata): void { @@ -333,7 +343,7 @@ export class MetadataStore implements StateStore { /** * Update the state * - * @param {string} module + * @param {string} module Module * @param {object} state to set */ protected updateAllModulesState(module: string, state: Metadata): void { @@ -367,9 +377,7 @@ export class MetadataStore implements StateStore { return this.recordGQL.fetch(this.resourceName, `/api/module-metadata/${module}`, fieldsToRetrieve) .pipe( - map(({data}) => { - return this.mapMetadata(module, data.moduleMetadata); - }) + map(({data}) => this.mapMetadata(module, data.moduleMetadata)) ); } @@ -441,6 +449,7 @@ export class MetadataStore implements StateStore { const entries = { templateMeta: 'templateMeta', actions: 'actions', + logic: 'logic', panels: 'panels', topWidget: 'topWidget', sidebarWidgets: 'sidebarWidgets', diff --git a/core/app/core/src/lib/views/create/store/create-view/create-view.store.ts b/core/app/core/src/lib/views/create/store/create-view/create-view.store.ts index f27118f765..c1be7e3606 100644 --- a/core/app/core/src/lib/views/create/store/create-view/create-view.store.ts +++ b/core/app/core/src/lib/views/create/store/create-view/create-view.store.ts @@ -45,6 +45,7 @@ import {MessageService} from '../../../../services/message/message.service'; import {Record, ViewMode} from 'common'; import {RecordStoreFactory} from '../../../../store/record/record.store.factory'; import {UserPreferenceStore} from '../../../../store/user-preference/user-preference.store'; +import {RecordLogicManager} from '../../../record/record-logic/record-logic.manager'; @Injectable() export class CreateViewStore extends RecordViewStore { @@ -64,7 +65,8 @@ export class CreateViewStore extends RecordViewStore { protected statisticsBatch: StatisticsBatch, protected auth: AuthService, protected recordStoreFactory: RecordStoreFactory, - protected preferences: UserPreferenceStore + protected preferences: UserPreferenceStore, + protected recordLogicManager: RecordLogicManager, ) { super( recordFetchGQL, @@ -80,7 +82,8 @@ export class CreateViewStore extends RecordViewStore { recordManager, statisticsBatch, recordStoreFactory, - preferences + preferences, + recordLogicManager, ); } @@ -177,6 +180,7 @@ export class CreateViewStore extends RecordViewStore { tap((data: Record) => { data.id = ''; data.attributes.id = ''; + // eslint-disable-next-line camelcase,@typescript-eslint/camelcase data.attributes.date_entered = ''; this.recordManager.injectParamFields(this.params, data, this.getVardefs()); this.recordStore.setRecord(data); diff --git a/core/app/core/src/lib/views/record/record-logic/record-logic.action.ts b/core/app/core/src/lib/views/record/record-logic/record-logic.action.ts new file mode 100755 index 0000000000..abeff32cd1 --- /dev/null +++ b/core/app/core/src/lib/views/record/record-logic/record-logic.action.ts @@ -0,0 +1,48 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2023 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ + +import { Action, ActionData, ActionHandler, Record } from 'common'; + +export interface RecordLogicActionData extends ActionData { + record?: Record; +} + +export abstract class RecordLogicActionHandler extends ActionHandler { + + /** + * Should Display + * + * @param {object} data Data + * @returns {boolean} Should Display? + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public shouldDisplay(data: RecordLogicActionData): boolean { + return true; + } + + abstract run(data: RecordLogicActionData, action: Action): void; + +} diff --git a/core/app/core/src/lib/views/record/record-logic/record-logic.manager.ts b/core/app/core/src/lib/views/record/record-logic/record-logic.manager.ts new file mode 100755 index 0000000000..15b7c4b06b --- /dev/null +++ b/core/app/core/src/lib/views/record/record-logic/record-logic.manager.ts @@ -0,0 +1,475 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2023 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ +import { Observable } from 'rxjs'; +import { first, switchMap, take } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { Action, deepClone, MapEntry, ModeActions, Record, RecordMapper, RecordMapperRegistry, ViewMode } from 'common'; +import { BaseActionManager } from '../../../services/actions/base-action-manager.service'; +import { RecordLogicActionData } from './record-logic.action'; +import { ConfirmationModalService } from '../../../services/modals/confirmation-modal.service'; +import { SelectModalService } from '../../../services/modals/select-modal.service'; +import { Process } from '../../../services/process/process.service'; +import { MessageService } from '../../../services/message/message.service'; +import { AsyncActionInput, AsyncActionService } from '../../../services/process/processes/async-action/async-action'; +import { MetadataStore } from '../../../store/metadata/metadata.store.service'; +import { RecordLogicContext } from './record-logic.model'; +import { ActiveLogicChecker } from '../../../services/logic/active-logic-checker.service'; +import { BaseSaveRecordMapper } from '../../../store/record/record-mappers/base-save.record-mapper'; +import { SubpanelStoreMap } from '../../../containers/subpanel/store/subpanel/subpanel.store'; +import { RecordList } from '../../../store/record-list/record-list.store'; + +@Injectable({ + providedIn: 'root', +}) +export class RecordLogicManager extends BaseActionManager { + + constructor( + protected confirmation: ConfirmationModalService, + protected selectModalService: SelectModalService, + protected asyncActionService: AsyncActionService, + protected message: MessageService, + protected metadataStore: MetadataStore, + protected activeLogicChecker: ActiveLogicChecker, + protected baseMapper: BaseSaveRecordMapper, + protected recordMappers: RecordMapperRegistry, + ) { + super(); + recordMappers.register('default', baseMapper.getKey(), baseMapper); + } + + /** + * Run logic for the given record + * + * @param {object} context Context + */ + public runLogic(context: RecordLogicContext): void { + if (!context && !context.logicEntries || !context.logicEntries.length) { + return; + } + + const logicActions = this.parseModeLogicActions(context.logicEntries, context.mode); + + logicActions.forEach(logicAction => { + const isActive = this.activeLogicChecker.run(context.record, logicAction); + if (!isActive) { + return; + } + + this.runAction(logicAction, context.mode, context); + }); + } + + /** + * Run the action using given context + * + * @param {Action} action Action + * @param {ViewMode} mode Mode + * @param {RecordLogicContext | null} context Context + */ + public runAction(action: Action, mode: ViewMode, context: RecordLogicContext = null): void { + const params = (action && action.params) || {} as { [key: string]: any }; + const displayConfirmation = params.displayConfirmation || false; + const confirmationLabel = params.confirmationLabel || ''; + + const selectModal = action.params && action.params.selectModal; + const selectModule = selectModal && selectModal.module; + + if (displayConfirmation) { + this.confirmation.showModal(confirmationLabel, () => { + if (!selectModule) { + this.callAction(action, context); + return; + } + this.showSelectModal(selectModal.module, action, mode, context); + }); + + return; + } + + if (!selectModule) { + this.callAction(action, context); + return; + } + + this.showSelectModal(selectModal.module, action, mode, context); + } + + /** + * Run async buk action + * + * @param {string} selectModule module for which records are listed in Select Modal/Popup + * @param {Action} asyncAction bulk action name + * @param {ViewMode} mode Mode + * @param {RecordLogicContext} context Context + */ + public showSelectModal( + selectModule: string, + asyncAction: Action, + mode: ViewMode, + context: RecordLogicContext = null, + ): void { + this.selectModalService.showSelectModal(selectModule, (modalRecord: Record) => { + if (modalRecord) { + const { ...baseRecord } = modalRecord; + asyncAction.params.modalRecord = baseRecord; + } + this.callAction(asyncAction, context); + }); + } + + /** + * Get Base Record + * + * @param {Record} record record + * @returns {Record} Base Record + */ + public getBaseRecord(record: Record): Record { + if (!record) { + return null; + } + + this.mapRecordFields(record); + + const baseRecord = { + id: record.id ?? '', + type: record.type ?? '', + module: record.module ?? '', + attributes: record.attributes ?? {}, + acls: record.acls ?? [], + } as Record; + + return deepClone(baseRecord); + } + + /** + * Map staging fields + * + * @param {Record} record Record + */ + protected mapRecordFields(record: Record): void { + const mappers: MapEntry = this.recordMappers.get(record.module); + + Object.keys(mappers).forEach(key => { + const mapper = mappers[key]; + mapper.map(record); + }); + } + + /** + * Call actions + * + * @param {Action} action Action + * @param {RecordLogicContext | null} context Context + */ + protected callAction(action: Action, context: RecordLogicContext = null): void { + if (action.asyncProcess) { + this.runAsyncAction(action, context); + return; + } + this.runFrontEndAction(action, context); + } + + /** + * Run async actions + * + * @param {Action} action Action + * @param {RecordLogicContext | null} context Context + */ + protected runAsyncAction(action: Action, context: RecordLogicContext = null): void { + const actionName = this.getActionName(action); + const moduleName = this.getModuleName(context); + + this.message.removeMessages(); + const asyncData = this.buildActionInput(action, context); + + const availableHandlers = [ + 'set-fields', + 'reload-subpanels', + ]; + + this.asyncActionService.run(actionName, asyncData, null, availableHandlers) + .pipe(take(1)) + .subscribe((process: Process) => { + this.afterAsyncAction(actionName, moduleName, asyncData, process, action, context); + }); + } + + /** + * Build backend process input + * + * @param {Action} action Action + * @param {RecordLogicContext | null} context Context + * @returns {AsyncActionInput} Action Input + */ + protected buildActionInput(action: Action, context: RecordLogicContext = null): AsyncActionInput { + const actionName = this.getActionName(action); + const baseRecord = this.getBaseRecord(context.record ?? {} as Record); + + this.message.removeMessages(); + + return { + action: actionName, + module: baseRecord.module, + id: baseRecord.id, + params: (action && action.params) || [], + record: baseRecord, + } as AsyncActionInput; + } + + /** + * Run after async action handlers + * + * @param {string} actionName Action Name + * @param {string} moduleName Module Name + * @param {AsyncActionInput} asyncData Async Data + * @param {Process} process Process + * @param {Action} action Action + * @param {RecordLogicContext} context Context + * @protected + */ + protected afterAsyncAction( + actionName: string, + moduleName: string, + asyncData: AsyncActionInput, + process: Process, + action: Action, + context: RecordLogicContext, + ): void { + if (this.shouldReload(process)) { + this.reload(action, process, context); + } + + const actionHandlerKey: string = process?.data?.handler ?? ''; + this.setFieldsAfterAction(actionHandlerKey, process, context); + this.reloadSubpanelAfterAction(actionHandlerKey, process, context); + + this.reloadMetadata(moduleName, action, process); + } + + /** + * Reload + * + * @param {Action} action Action + * @param {Process} process Process + * @param {RecordLogicContext} context Context + * @protected + */ + protected reload(action: Action, process: Process, context?: RecordLogicContext): void { + if (!context.reload) { + return; + } + context.reload().pipe(take(1)).subscribe(); + } + + /** + * Should reload page + * + * @param {Process} process Process + * @returns {boolean} Should Reload? + */ + protected shouldReloadRecentlyViewed(process: Process): boolean { + return !!(process.data && process.data.reloadRecentlyViewed); + } + + /** + * Should reload page + * + * @param {Process} process Process + * @returns {boolean} Should Reload? + */ + protected shouldReloadFavorites(process: Process): boolean { + return !!(process.data && process.data.reloadFavorites); + } + + /** + * Should reload page + * + * @param {Process} process Process + * @returns {boolean} Should Reload? + */ + protected shouldReload(process: Process): boolean { + return !!(process.data && process.data.reload); + } + + /** + * Reload the metadata for the module + * + * @param {string} moduleName Module Name + * @param {Action} action Action + * @param {Process} process Process + * @protected + */ + protected reloadMetadata(moduleName: string, action: Action, process: Process): void { + const typesToLoad = []; + + if (this.shouldReloadRecentlyViewed(process)) { + typesToLoad.push(this.metadataStore.typeKeys.recentlyViewed); + } + + if (this.shouldReloadFavorites(process)) { + typesToLoad.push(this.metadataStore.typeKeys.favorites); + } + + if (typesToLoad && typesToLoad.length) { + this.metadataStore.reloadModuleMetadata(moduleName, typesToLoad, false) + .pipe(take(1)) + .subscribe(); + } + } + + /** + * Get module name + * + * @param {object} context Context + * @returns {string} Module Name + */ + protected getModuleName(context?: RecordLogicContext): string { + return context.record.module; + } + + /** + * Get action name + * + * @param {Action} action Action + * @returns {string} Action Name + */ + protected getActionName(action: Action): string { + return `${action.key}`; + } + + /** + * Run front end action + * + * @param {Action} action Action + * @param {RecordLogicContext} context Context + */ + protected runFrontEndAction(action: Action, context: RecordLogicContext = null): void { + const data: RecordLogicActionData = this.buildActionData(action, context); + + // TO DO ADD access to the store! + + this.run(action, context.mode, data); + } + + /** + * Build Action Data + * + * @param {Action} action Action + * @param {RecordLogicContext} context Context + * @returns {RecordLogicActionData} Action Data + * @protected + */ + protected buildActionData(action: Action, context?: RecordLogicContext): RecordLogicActionData { + return { + record: (context && context.record) || null, + } as RecordLogicActionData; + } + + /** + * Parse mode actions + * + * @param {Action[]} declaredActions Declared Actions + * @param {ViewMode} mode Mode + * @returns {Action[]} Logic Actions + */ + protected parseModeLogicActions(declaredActions: Action[], mode: ViewMode): Action[] { + if (!declaredActions) { + return []; + } + + const availableActions = { + list: [], + detail: [], + edit: [], + create: [], + massupdate: [], + filter: [], + } as ModeActions; + + if (declaredActions && declaredActions.length) { + declaredActions.forEach(action => { + if (!action.modes || !action.modes.length) { + return; + } + + action.modes.forEach(actionMode => { + if (!availableActions[actionMode] && !action.asyncProcess) { + return; + } + availableActions[actionMode].push(action); + }); + }); + } + + const actions = []; + + availableActions[mode].forEach(action => { + actions.push(action); + }); + + return actions; + } + + private setFieldsAfterAction(actionHandlerKey: string, process: Process, context: RecordLogicContext): void { + if (actionHandlerKey !== 'set-fields') { + return; + } + + const params = process?.data?.params ?? {}; + const fieldsValuesToSet = params.fieldValues ?? null; + + if (!fieldsValuesToSet) { + return; + } + + Object.keys(fieldsValuesToSet).forEach(fieldName => { + const fieldValue = fieldsValuesToSet[fieldName] ?? null; + if (fieldValue === null || !context?.record?.fields[fieldName]) { + return; + } + + context.record.fields[fieldName].value = fieldValue; + context.record.fields[fieldName].formControl.setValue(fieldValue); + }); + } + + private reloadSubpanelAfterAction(actionHandlerKey: string, process: Process, context: RecordLogicContext): void { + if (actionHandlerKey !== 'reload-subpanels') { + return; + } + + const params = process?.data?.params ?? {}; + const subpanelNames: string[] = params.subpanelNames ?? []; + + const subpanelSelectorMap = (subpanels: SubpanelStoreMap): Array | undefined> => + (subpanelNames.map(subpanelName => subpanels[subpanelName]?.load(false))); + + context.subpanels$ + .pipe(first(), switchMap(subpanelSelectorMap)) + .subscribe((singleSubpanelLoad$) => singleSubpanelLoad$?.subscribe()); + } + +} diff --git a/core/app/core/src/lib/views/record/record-logic/record-logic.model.ts b/core/app/core/src/lib/views/record/record-logic/record-logic.model.ts new file mode 100755 index 0000000000..3c4bc4850c --- /dev/null +++ b/core/app/core/src/lib/views/record/record-logic/record-logic.model.ts @@ -0,0 +1,37 @@ +/** + * SuiteCRM is a customer relationship management program developed by SalesAgility Ltd. + * Copyright (C) 2023 SalesAgility Ltd. + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License version 3 as published by the + * Free Software Foundation with the addition of the following permission added + * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK + * IN WHICH THE COPYRIGHT IS OWNED BY SALESAGILITY, SALESAGILITY DISCLAIMS THE + * WARRANTY OF NON INFRINGEMENT OF THIRD PARTY RIGHTS. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * In accordance with Section 7(b) of the GNU Affero General Public License + * version 3, these Appropriate Legal Notices must retain the display of the + * "Supercharged by SuiteCRM" logo. If the display of the logos is not reasonably + * feasible for technical reasons, the Appropriate Legal Notices must display + * the words "Supercharged by SuiteCRM". + */ +import { Observable } from 'rxjs'; +import { ActionContext, Record, RecordLogic, ViewMode } from 'common'; +import { SubpanelStoreMap } from '../../../containers/subpanel/store/subpanel/subpanel.store'; + +export type RecordStoreReload = () => Observable; + +export interface RecordLogicContext extends ActionContext { + logicEntries?: RecordLogic[]; + subpanels$?: Observable; + mode?: ViewMode; + reload?: RecordStoreReload; +} diff --git a/core/app/core/src/lib/views/record/store/record-view/record-view.store.model.ts b/core/app/core/src/lib/views/record/store/record-view/record-view.store.model.ts index d0fd028cd7..a5404e58e6 100644 --- a/core/app/core/src/lib/views/record/store/record-view/record-view.store.model.ts +++ b/core/app/core/src/lib/views/record/store/record-view/record-view.store.model.ts @@ -24,7 +24,7 @@ * the words "Supercharged by SuiteCRM". */ -import {Record, ViewMode} from 'common'; +import { Record, RecordLogicMap, ViewMode } from 'common'; import {AppData} from '../../../../store/view/view.store'; import {Metadata} from '../../../../store/metadata/metadata.store.service'; @@ -53,3 +53,7 @@ export interface RecordViewState { mode: ViewMode; params: { [key: string]: string }; } + +export interface RecordLogicMapPerField { + [fieldName: string]: RecordLogicMap; +} diff --git a/core/app/core/src/lib/views/record/store/record-view/record-view.store.ts b/core/app/core/src/lib/views/record/store/record-view/record-view.store.ts index 69fd742fca..89d61af7cd 100644 --- a/core/app/core/src/lib/views/record/store/record-view/record-view.store.ts +++ b/core/app/core/src/lib/views/record/store/record-view/record-view.store.ts @@ -24,23 +24,28 @@ * the words "Supercharged by SuiteCRM". */ +import { cloneDeep, isEmpty, isEqual } from 'lodash-es'; +import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs'; +import { catchError, distinctUntilChanged, finalize, map, take, tap } from 'rxjs/operators'; import {Injectable} from '@angular/core'; -import {BehaviorSubject, combineLatest, Observable, of, Subscription} from 'rxjs'; +import { Params } from '@angular/router'; import { BooleanMap, deepClone, FieldDefinitionMap, + FieldValue, + FieldValueMap, isVoid, Record, + RecordLogicMap, StatisticsMap, StatisticsQueryMap, SubPanelMeta, ViewContext, ViewFieldDefinition, - ViewMode + ViewMode, } from 'common'; -import {catchError, distinctUntilChanged, finalize, map, take, tap} from 'rxjs/operators'; -import {RecordViewData, RecordViewModel, RecordViewState} from './record-view.store.model'; +import { RecordLogicMapPerField, RecordViewData, RecordViewModel, RecordViewState } from './record-view.store.model'; import {NavigationStore} from '../../../../store/navigation/navigation.store'; import {StateStore} from '../../../../store/state'; import {RecordSaveGQL} from '../../../../store/record/graphql/api.record.save'; @@ -61,10 +66,11 @@ import {LocalStorageService} from '../../../../services/local-storage/local-stor import {SubpanelStoreFactory} from '../../../../containers/subpanel/store/subpanel/subpanel.store.factory'; import {ViewStore} from '../../../../store/view/view.store'; import {RecordFetchGQL} from '../../../../store/record/graphql/api.record.get'; -import {Params} from '@angular/router'; import {StatisticsBatch} from '../../../../store/statistics/statistics-batch.service'; import {RecordStoreFactory} from '../../../../store/record/record.store.factory'; import {UserPreferenceStore} from '../../../../store/user-preference/user-preference.store'; +import { RecordLogicManager } from '../../record-logic/record-logic.manager'; +import { RecordLogicContext } from '../../record-logic/record-logic.model'; const initialState: RecordViewState = { module: '', @@ -119,6 +125,8 @@ export class RecordViewStore extends ViewStore implements StateStore { protected subpanelReloadSubject = new BehaviorSubject({} as BooleanMap); protected subpanelReloadSub: Subscription[] = []; protected subs: Subscription[] = []; + protected logicSubs: Subscription[] = []; + protected recordLogicPrevValues: FieldValueMap = {}; constructor( protected recordFetchGQL: RecordFetchGQL, @@ -134,7 +142,8 @@ export class RecordViewStore extends ViewStore implements StateStore { protected recordManager: RecordManager, protected statisticsBatch: StatisticsBatch, protected recordStoreFactory: RecordStoreFactory, - protected preferences: UserPreferenceStore + protected preferences: UserPreferenceStore, + protected recordLogicManager: RecordLogicManager, ) { super(appStateStore, languageStore, navigationStore, moduleNavigation, metadataStore); @@ -170,9 +179,7 @@ export class RecordViewStore extends ViewStore implements StateStore { this.subpanels$ = this.subpanelsState.asObservable(); - this.viewContext$ = this.record$.pipe(map(() => { - return this.getViewContext(); - })); + this.viewContext$ = this.record$.pipe(map(() => this.getViewContext())); } get widgets(): boolean { @@ -276,6 +283,17 @@ export class RecordViewStore extends ViewStore implements StateStore { this.calculateShowWidgets(); + this.recordLogicPrevValues = {}; + this.subs.push( + this.stagingRecord$.subscribe(stagingRecord => { + if (!stagingRecord || !stagingRecord.fields) { + return; + } + this.recordLogicPrevValues = {}; + this.initializeRecordLogic(stagingRecord); + }) + ); + return this.load().pipe( tap(() => { this.showTopWidget = true; @@ -293,6 +311,8 @@ export class RecordViewStore extends ViewStore implements StateStore { this.clearSubpanels(); this.subpanelsState.unsubscribe(); this.updateState(deepClone(initialState)); + this.logicSubs = this.safeUnsubscription(this.logicSubs); + this.recordLogicPrevValues = {}; } /** @@ -365,6 +385,8 @@ export class RecordViewStore extends ViewStore implements StateStore { loading: true }); + this.recordLogicPrevValues = {}; + return this.recordStore.retrieveRecord( this.internalState.module, this.internalState.recordID, @@ -602,8 +624,10 @@ export class RecordViewStore extends ViewStore implements StateStore { /** * Build ui user preference key - * @param storageKey + * + * @param {string} storageKey Storage Key * @protected + * @returns {string} Preference Key */ protected getPreferenceKey(storageKey: string): string { return 'recordview-' + storageKey; @@ -611,9 +635,10 @@ export class RecordViewStore extends ViewStore implements StateStore { /** * Save ui user preference - * @param module - * @param storageKey - * @param value + * + * @param {string} module Module + * @param {string} storageKey Storage Key + * @param {any} value Value * @protected */ protected savePreference(module: string, storageKey: string, value: any): void { @@ -622,9 +647,11 @@ export class RecordViewStore extends ViewStore implements StateStore { /** * Load ui user preference - * @param module - * @param storageKey + * + * @param {string} module Module + * @param {string} storageKey Storage Key * @protected + * @returns {any} User Preference */ protected loadPreference(module: string, storageKey: string): any { return this.preferences.getUi(module, this.getPreferenceKey(storageKey)); @@ -678,4 +705,104 @@ export class RecordViewStore extends ViewStore implements StateStore { }); } + private initializeRecordLogic(stagingRecord: Record): void { + this.logicSubs = this.safeUnsubscription(this.logicSubs); + + const recordViewMetadata = this.getRecordViewMetadata(); + if (isEmpty(recordViewMetadata.logic)) { + return; + } + + const recordLogicsPerFields = this.buildLogicConfigMap(recordViewMetadata.logic); + if (isEmpty(recordLogicsPerFields)) { + return; + } + + this.initializeLogicSubscriptions(stagingRecord, recordViewMetadata.logic); + } + + private initializeLogicSubscriptions(stagingRecord: Record, recordLogicMap: RecordLogicMap): void { + Object.keys(recordLogicMap).forEach((recordLogicKey) => { + const recordLogic = recordLogicMap[recordLogicKey]; + const dependentFields = recordLogic?.params?.fieldDependencies ?? []; + + if (isEmpty(dependentFields)) { + return; + } + + const dependentFieldsValueChanges$ = []; + dependentFields.forEach(fieldName => { + const field = stagingRecord.fields[fieldName]; + if (isEmpty(field)) { + return; + } + + dependentFieldsValueChanges$.push(field.valueChanges$); + }); + + this.logicSubs.push( + combineLatest(dependentFieldsValueChanges$).subscribe((valueChanges: FieldValue[]) => { + this.recordLogicPrevValues[recordLogicKey] = this.recordLogicPrevValues[recordLogicKey] ?? {}; + const recordLogicPrevValues = this.recordLogicPrevValues[recordLogicKey]; + + let isSame = true; + dependentFields.forEach((fieldName, index) => { + const prevValue: FieldValue = recordLogicPrevValues[fieldName] ?? {}; + const currValue: FieldValue = cloneDeep({ + value: valueChanges[index].value, + valueList: valueChanges[index].valueList, + valueObject: valueChanges[index].valueObject + }); + isSame = isSame && isEqual(prevValue, currValue); + + recordLogicPrevValues[fieldName] = currValue; + }); + + if (isSame === true) { + return; + } + + const recordLogicContext: RecordLogicContext = { + mode: this.getMode(), + record: stagingRecord, + subpanels$: this.subpanels$, + reload: () => this.load(false), + }; + + this.recordLogicManager.runLogic({ + ...recordLogicContext, + logicEntries: [recordLogic], + }); + }), + ); + }); + } + + private buildLogicConfigMap(recordLogicMap: RecordLogicMap): RecordLogicMapPerField { + const logicConfigsPerField: RecordLogicMapPerField = {}; + + Object.entries(recordLogicMap).forEach(([recordLogicName, recordLogic]) => { + const dependentFields = recordLogic?.params?.fieldDependencies ?? []; + + dependentFields.forEach(fieldName => { + logicConfigsPerField[fieldName] = logicConfigsPerField[fieldName] ?? {}; + logicConfigsPerField[fieldName][recordLogicName] = recordLogic; + }); + }); + + return logicConfigsPerField; + } + + private safeUnsubscription(subscriptionArray: Subscription[]): Subscription[] { + subscriptionArray.forEach(sub => { + if (sub.closed) { + return; + } + + sub.unsubscribe(); + }); + subscriptionArray = []; + + return subscriptionArray; + } }