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;
+ }
}