diff --git a/core/app/common/src/lib/record/field.model.ts b/core/app/common/src/lib/record/field.model.ts
index 038ad645c3..8351ede492 100644
--- a/core/app/common/src/lib/record/field.model.ts
+++ b/core/app/common/src/lib/record/field.model.ts
@@ -24,13 +24,15 @@
* the words "Supercharged by SuiteCRM".
*/
-import {SearchCriteriaFieldFilter} from '../views/list/search-criteria.model';
+import {isArray, isEqual, isObject, isString, uniq} from 'lodash-es';
import {BehaviorSubject, Observable} from 'rxjs';
import {AsyncValidatorFn, FormArray, FormControl, ValidatorFn} from '@angular/forms';
+import {SearchCriteriaFieldFilter} from '../views/list/search-criteria.model';
import {Record} from './record.model';
import {FieldLogicMap} from '../actions/field-logic-action.model';
import {ObjectMap} from '../types/object-map';
import {ViewMode} from '../views/view.model';
+import {deepClone} from '../utils/object-utils';
export type DisplayType = 'none' | 'show' | 'readonly' | 'inline' | 'disabled' | 'default';
@@ -97,7 +99,7 @@ export interface FieldDefinition {
default?: string;
modes?: ViewMode[];
relationship?: string;
- relationshipMetadata?: RelationshipMetadata
+ relationshipMetadata?: RelationshipMetadata;
[key: string]: any;
}
@@ -157,6 +159,16 @@ export interface AttributeDependency {
types: string[];
}
+export interface FieldValue {
+ value?: string;
+ valueList?: string[];
+ valueObject?: any;
+}
+
+export interface FieldValueMap {
+ [key: string]: FieldValue;
+}
+
export interface Field {
type: string;
value?: string;
@@ -232,13 +244,19 @@ export class BaseField implements Field {
}
set value(value: string) {
- const changed = value !== this.valueState;
+ if (!isString(value)) {
+ this.setValue(value);
+ return;
+ }
- this.valueState = value;
+ const valueClean: string = value.trim();
- if (changed) {
- this.emitValueChanges();
+ if (isEqual(this.valueState, valueClean)) {
+ return;
}
+
+ this.valueState = valueClean;
+ this.emitValueChanges();
}
get valueList(): string[] {
@@ -246,9 +264,18 @@ export class BaseField implements Field {
}
set valueList(value: string[]) {
+ if (!isArray(value)) {
+ this.setValue(value);
+ return;
+ }
- this.valueListState = value;
+ const valueListClean: string[] = uniq(deepClone(value));
+
+ if (isEqual(this.valueListState, valueListClean)) {
+ return;
+ }
+ this.valueListState = valueListClean;
this.emitValueChanges();
}
@@ -257,7 +284,16 @@ export class BaseField implements Field {
}
set valueObject(value: any) {
- this.valueObjectState = value;
+ if (!isObject(value)) {
+ this.setValue(value);
+ return;
+ }
+
+ if (isEqual(this.valueObjectState, value)) {
+ return;
+ }
+
+ this.valueObjectState = deepClone(value);
this.emitValueChanges();
}
@@ -266,23 +302,32 @@ export class BaseField implements Field {
}
set valueObjectArray(value: ObjectMap[]) {
+ if (isEqual(this.valueObjectArrayState, value)) {
+ return;
+ }
+
this.valueObjectArrayState = value;
this.emitValueChanges();
}
- protected emitValueChanges() {
+ public setValue(value: string | string[] | any): void {
+ if (isString(value)) {
+ this.value = value;
+ } else if (isArray(value)) {
+ this.valueList = value;
+ } else if (isObject(value)) {
+ this.valueObject = value;
+ } else {
+ this.value = value?.toString() ?? '';
+ }
+ }
+
+ protected emitValueChanges(): void {
this.valueSubject.next({
value: this.valueState,
valueList: this.valueListState,
valueObject: this.valueObjectState
- })
+ });
}
-}
-export interface FieldValue {
- value?: string;
- valueList?: string[];
- valueObject?: any;
}
-
-
diff --git a/core/app/core/src/lib/core.ts b/core/app/core/src/lib/core.ts
index ab8b035733..8fd2261d52 100644
--- a/core/app/core/src/lib/core.ts
+++ b/core/app/core/src/lib/core.ts
@@ -361,11 +361,13 @@ export * from './fields/enum/templates/edit/enum.component';
export * from './fields/enum/templates/edit/enum.module';
export * from './fields/field-logic/field-logic.action';
export * from './fields/field-logic/field-logic.manager';
+export * from './fields/field-logic/actionable-field-logic/actionable-field-logic.action';
export * from './fields/field-logic/currency-conversion/update-base-currency.action';
export * from './fields/field-logic/currency-conversion/update-currency.action';
export * from './fields/field-logic/display-type/display-type.action';
export * from './fields/field-logic/email-primary-select/email-primary-select.action';
export * from './fields/field-logic/required/required.action';
+export * from './fields/field-logic/set-field-from-related/set-field-from-related.action';
export * from './fields/field-logic/update-flex-relate-module/update-flex-relate-module.action';
export * from './fields/file/templates/detail/file.component';
export * from './fields/file/templates/detail/file.module';
@@ -453,6 +455,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';
diff --git a/core/app/core/src/lib/fields/field-logic/actionable-field-logic/actionable-field-logic.action.ts b/core/app/core/src/lib/fields/field-logic/actionable-field-logic/actionable-field-logic.action.ts
new file mode 100644
index 0000000000..243c141433
--- /dev/null
+++ b/core/app/core/src/lib/fields/field-logic/actionable-field-logic/actionable-field-logic.action.ts
@@ -0,0 +1,84 @@
+/**
+ * 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, isString} from 'lodash-es';
+import {
+ Action,
+ ALL_VIEW_MODES,
+ Field,
+ Record,
+} from 'common';
+import { FieldLogicActionData, FieldLogicActionHandler } from '../field-logic.action';
+import {ActiveLogicChecker} from '../../../services/logic/active-logic-checker.service';
+
+export type FieldValueTypes = string | string[] | object;
+
+export abstract class ActionableFieldLogicActionHandler extends FieldLogicActionHandler {
+ modes = ALL_VIEW_MODES;
+
+ protected constructor(
+ protected activeLogicChecker: ActiveLogicChecker
+ ) {
+ super();
+ }
+
+ run(data: FieldLogicActionData, action: Action): void {
+ const record = data.record;
+ const field = data.field;
+ if (!record || !field) {
+ return;
+ }
+ const params = action.params ?? {};
+
+ const logicIsActive = this.activeLogicChecker.run(record, action);
+
+ this.executeLogic(logicIsActive, params, field, record);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ shouldDisplay(data: FieldLogicActionData): boolean {
+ return true;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ executeLogic(logicIsActive: boolean, params: { [p: string]: any }, field: Field, record: Record): void {
+ }
+
+ protected updateValue(value: FieldValueTypes, field: Field, record: Record): void {
+ if (isString(value)) {
+ field.value = value;
+ } else if (isArray(value)) {
+ field.valueList = value;
+ } else {
+ field.valueObject = value;
+ }
+
+ field.formControl.setValue(value);
+
+ // re-validate the parent form-control after value update
+ record.formGroup.updateValueAndValidity({onlySelf: true, emitEvent: true});
+ }
+}
diff --git a/core/app/core/src/lib/fields/field-logic/field-logic.manager.ts b/core/app/core/src/lib/fields/field-logic/field-logic.manager.ts
index 15ba9dbf11..74acb4e927 100644
--- a/core/app/core/src/lib/fields/field-logic/field-logic.manager.ts
+++ b/core/app/core/src/lib/fields/field-logic/field-logic.manager.ts
@@ -1,6 +1,6 @@
/**
* SuiteCRM is a customer relationship management program developed by SalesAgility Ltd.
- * Copyright (C) 2021 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
@@ -27,7 +27,7 @@
import {Injectable} from '@angular/core';
import {BaseActionManager} from '../../services/actions/base-action-manager.service';
import {FieldLogicActionData, FieldLogicActionHandlerMap} from './field-logic.action';
-import {Action, ActionContext, ActionHandlerMap, Field, ModeActions, Record, ViewMode} from 'common';
+import {Action, ActionContext, Field, ModeActions, Record, ViewMode} from 'common';
import {DisplayTypeAction} from './display-type/display-type.action';
import {EmailPrimarySelectAction} from './email-primary-select/email-primary-select.action';
import {RequiredAction} from './required/required.action';
@@ -37,7 +37,7 @@ import {UpdateFlexRelateModuleAction} from './update-flex-relate-module/update-f
import {UpdateValueAction} from './update-value/update-value.action';
import {UpdateValueBackendAction} from './update-value-backend/update-value-backend.action';
import {DisplayTypeBackendAction} from './display-type-backend/display-type-backend.action';
-import {RecordActionData} from '../../views/record/actions/record.action';
+import {SetFieldFromRelatedAction} from './set-field-from-related/set-field-from-related.action';
@Injectable({
providedIn: 'root'
@@ -62,7 +62,8 @@ export class FieldLogicManager extends BaseActionManager {
updateValue: UpdateValueAction,
updateFlexRelateModule: UpdateFlexRelateModuleAction,
updateValueBackend: UpdateValueBackendAction,
- dislayTypeBackend: DisplayTypeBackendAction
+ dislayTypeBackend: DisplayTypeBackendAction,
+ setFieldFromRelatedAction: SetFieldFromRelatedAction
) {
super();
displayType.modes.forEach(mode => this.actions[mode][displayType.key] = displayType);
@@ -74,16 +75,18 @@ export class FieldLogicManager extends BaseActionManager {
updateValue.modes.forEach(mode => this.actions[mode][updateValue.key] = updateValue);
updateValueBackend.modes.forEach(mode => this.actions[mode][updateValueBackend.key] = updateValueBackend);
dislayTypeBackend.modes.forEach(mode => this.actions[mode][dislayTypeBackend.key] = dislayTypeBackend);
+ setFieldFromRelatedAction.modes.forEach(mode => this.actions[mode][setFieldFromRelatedAction.key] = setFieldFromRelatedAction);
}
/**
* Run logic for the given field
- * @param {object} field
- * @param {object} mode
- * @param {object} record
- * @param triggeringStatus
+ *
+ * @param {Field} field Field
+ * @param {ViewMode} mode Mode
+ * @param {Record} record Record
+ * @param {string} triggeringStatus Triggering Status
*/
- runLogic(field: Field, mode: ViewMode, record: Record, triggeringStatus: string = ''): void {
+ runLogic(field: Field, mode: ViewMode, record: Record, triggeringStatus = ''): void {
if (!field.logic) {
return;
}
@@ -104,6 +107,7 @@ export class FieldLogicManager extends BaseActionManager {
/**
* Run the action using given context
+ *
* @param action
* @param mode
* @param context
@@ -114,6 +118,7 @@ export class FieldLogicManager extends BaseActionManager {
/**
* Run front end action
+ *
* @param {object} action
* @param {object} mode
* @param {object} context
@@ -126,6 +131,7 @@ export class FieldLogicManager extends BaseActionManager {
/**
* Get module name
+ *
* @param {object} context
*/
protected getModuleName(context?: ActionContext): string {
@@ -141,6 +147,7 @@ export class FieldLogicManager extends BaseActionManager {
/**
* Parse mode actions
+ *
* @param declaredActions
* @param mode
* @param triggeringStatus
diff --git a/core/app/core/src/lib/fields/field-logic/set-field-from-related/set-field-from-related.action.ts b/core/app/core/src/lib/fields/field-logic/set-field-from-related/set-field-from-related.action.ts
new file mode 100644
index 0000000000..b634bb2d4e
--- /dev/null
+++ b/core/app/core/src/lib/fields/field-logic/set-field-from-related/set-field-from-related.action.ts
@@ -0,0 +1,96 @@
+/**
+ * @author SalesAgility .
+ */
+
+import {Injectable} from '@angular/core';
+import {AttributeMap, EDITABLE_VIEW_MODES, Field, Record} from 'common';
+import {
+ ActionableFieldLogicActionHandler,
+ FieldValueTypes
+} from '../actionable-field-logic/actionable-field-logic.action';
+import {ActiveLogicChecker} from '../../../services/logic/active-logic-checker.service';
+import {map, take} from 'rxjs/operators';
+import {RecordFetchGQL} from '../../../store/record/graphql/api.record.get';
+import {Observable, of} from 'rxjs';
+import {isString} from 'lodash-es';
+
+
+type RelatedFieldParamType = string | {
+ linkFieldName: string;
+ toCopyFromFieldName: string;
+};
+
+interface RelatedFieldParams {
+ nonActiveState?: RelatedFieldParamType;
+ activeState?: RelatedFieldParamType;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class SetFieldFromRelatedAction extends ActionableFieldLogicActionHandler {
+
+ key = 'setFromRelated';
+ modes = EDITABLE_VIEW_MODES;
+
+ constructor(
+ protected activeLogicChecker: ActiveLogicChecker,
+ protected recordFetchGQL: RecordFetchGQL
+ ) {
+ super(activeLogicChecker);
+ }
+
+ executeLogic(logicIsActive: boolean, params: RelatedFieldParams, field: Field, record: Record): void {
+ this.getToUpdateValue(logicIsActive, params, record)
+ .pipe(take(1))
+ .subscribe((toUpdateValue) => {
+ if (toUpdateValue === null) {
+ return;
+ }
+
+ this.updateValue(toUpdateValue, field, record);
+ });
+ }
+
+ private getToUpdateValue(logicIsActive: boolean, params: RelatedFieldParams, record: Record): Observable {
+ const paramAccordingToLogicState = logicIsActive
+ ? params.activeState
+ : params.nonActiveState;
+ const relatedFieldParam = paramAccordingToLogicState ?? null;
+
+ if (relatedFieldParam === null) {
+ return of(null);
+ }
+
+ if (isString(relatedFieldParam)) {
+ return of(relatedFieldParam);
+ }
+
+ const toCopyFromFieldName = relatedFieldParam.toCopyFromFieldName;
+ if (!toCopyFromFieldName) {
+ return of(null);
+ }
+
+ const linkFieldName = relatedFieldParam.linkFieldName;
+ const relatedIdNameField = record.fields[linkFieldName];
+ const relatedIdField = record.fields[relatedIdNameField.definition.id_name];
+ if (!relatedIdField) {
+ return of(null);
+ }
+
+ const module: string = relatedIdField.definition.module;
+ const recordId: string = relatedIdField.value;
+
+ return this.getRecordAttributes(module, recordId).pipe(
+ map((recordAttributes): (FieldValueTypes | null) => (recordAttributes[toCopyFromFieldName] ?? null))
+ );
+ }
+
+ private getRecordAttributes(module: string, recordId: string): Observable {
+ const fields: string[] = ['_id', 'attributes'];
+
+ return this.recordFetchGQL.fetch(module, recordId, {fields}).pipe(
+ map((result): AttributeMap => (result?.data?.getRecord?.attributes ?? {}))
+ );
+ }
+}
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);
+ });
+ }
+}