cascading links

This commit is contained in:
Yurii
2026-03-02 17:57:33 +02:00
parent 38ab9de081
commit 74a3e9f6bd
22 changed files with 2023 additions and 173 deletions
@@ -78,6 +78,12 @@
<div class="field" data-name="dynamicLogicReadOnlySaved">{{{dynamicLogicReadOnlySaved}}}</div>
</div>
{{/if}}
{{#if dynamicLogicCascading}}
<div class="cell form-group" data-name="dynamicLogicCascading">
<label class="control-label" data-name="dynamicLogicCascading">{{translate 'dynamicLogicCascading' scope='Admin' category='fields'}}</label>
<div class="field" data-name="dynamicLogicCascading">{{{dynamicLogicCascading}}}</div>
</div>
{{/if}}
</div>
</div>
</div>
+182 -43
View File
@@ -29,54 +29,111 @@
/** @module dynamic-logic */
/**
* Dynamic logic. Handles form appearance and behaviour depending on conditions.
* @typedef {Record} module:dynamic-logic~defs
* @property {Object.<string, module:dynamic-logic~fieldDefs>} [fields] Fields.
* @property {Object.<string, module:dynamic-logic~panelDefs>} [panels] Panels.
* @property {Object.<string, *>} [options] Options.
* @property {Object.<string, module:dynamic-logic~cascadingFieldDefs>} [cascadingFields] Cascading fields.
*/
/**
* @typedef {Record} module:dynamic-logic~fieldDefs
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [visible] Visibility conditions.
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [required] Requiring conditions.
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [readOnly] Read-only conditions.
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [readOnlySaved] Read-only saved conditions.
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [invalid] Invalidity conditions.
*/
/**
* @typedef {Record} module:dynamic-logic~panelDefs
* @property {{conditionGroup: module:dynamic-logic~conditionGroup}} [visible] Visibility conditions.
*/
/**
* @typedef {Record} module:dynamic-logic~cascadingFieldDefs
* @property {{localField: string, foreignField: string, matchRequired: boolean}[]} [items] Visibility conditions.
*/
/**
* @typedef {Record[]} module:dynamic-logic~conditionGroup
*/
import {inject} from 'di';
import FieldManager from 'field-manager';
/**
* Dynamic logic. Handles form appearance and behavior depending on conditions.
*
* @internal Instantiated in advanced-pack.
*/
class DynamicLogic {
/**
* @param {Object} defs Definitions.
* @param {module:views/record/base} recordView A record view.
* @private
* @type {module:dynamic-logic~defs} defs Definitions.
*/
defs
/**
* @private
* @type {import('views/record/base').default}
*/
recordView
/**
* @private
* @type {string[]}
* @const
*/
fieldTypeList = [
'visible',
'required',
'readOnlySaved',
'readOnly',
]
/**
* @private
* @type {string[]}
*/
panelTypeList = [
'visible',
'styled',
]
/**
* @private
* @type {Object.<string, string[]>}
*/
cascadingClearDefs
/**
* @private
* @type {FieldManager}
*/
@inject(FieldManager)
fieldManager
/**
* @param {module:dynamic-logic~defs} defs Definitions.
* @param {import('views/record/base').default} recordView A record view.
*/
constructor(defs, recordView) {
/**
* @type {Object} Definitions.
* @private
*/
this.defs = defs || {};
/**
*
* @type {module:views/record/base}
* @private
*/
this.defs = defs ?? {};
this.recordView = recordView;
/**
* @type {string[]}
* @private
*/
this.fieldTypeList = [
'visible',
'required',
'readOnlySaved',
'readOnly',
];
/**
* @type {string[]}
* @private
*/
this.panelTypeList = ['visible', 'styled'];
this.cascadingClearDefs = this.buildCascadingClearDefs();
}
/**
* Process.
*
* @param {{action?: string|'ui'}} [options] Options.
*/
process() {
const fields = this.defs.fields || {};
process(options = {}) {
const fields = this.defs.fields ?? {};
Object.keys(fields).forEach(field => {
/** @type {Record} */
@@ -138,7 +195,7 @@ class DynamicLogic {
});
});
const panels = this.defs.panels || {};
const panels = this.defs.panels ?? {};
Object.keys(panels).forEach(panel => {
this.panelTypeList.forEach(type => {
@@ -146,12 +203,12 @@ class DynamicLogic {
});
});
const options = this.defs.options || {};
const optionsDefs = this.defs.options ?? {};
Object.keys(options).forEach(field => {
const itemList = options[field];
Object.keys(optionsDefs).forEach(field => {
const itemList = optionsDefs[field];
if (!options[field]) {
if (!optionsDefs[field]) {
return;
}
@@ -173,6 +230,88 @@ class DynamicLogic {
this.resetOptionList(field);
}
});
if (options.action === 'ui') {
this.processCascadingClear();
}
}
/**
* @private
* @return {Object.<string, string[]>}
*/
buildCascadingClearDefs() {
const fields = this.defs.cascadingFields ?? null;
if (!fields || Object.keys(fields).length === 0) {
return {};
}
const model = this.recordView.model;
/** @type {Object.<string, string[]>} */
const map = {};
for (const [field, defs] of Object.entries(fields)) {
const items = defs.items;
if (!items || !items.length) {
continue;
}
for (const item of items) {
if (!item.matchRequired) {
continue;
}
const type = model.getFieldType(item.localField);
const idAttribute = type === 'linkMultiple' ? item.localField + 'Ids' : item.localField + 'Id';
map[idAttribute] ??= [];
const attributeList = this.fieldManager.getEntityTypeFieldAttributeList(model.entityType, field);
map[idAttribute].push(...attributeList);
}
}
for (const [attribute, a] of Object.entries(map)) {
map[attribute] = a.filter((it, i) => a.indexOf(it) === i);
}
return map;
}
/**
* @private
*/
processCascadingClear() {
const attributeList = Object.keys(this.cascadingClearDefs);
const model = this.recordView.model;
const fieldsToUnset = [];
for (const attribute of attributeList) {
if (model.hasChanged(attribute)) {
fieldsToUnset.push(...this.cascadingClearDefs[attribute]);
}
}
if (!fieldsToUnset.length) {
return;
}
const map = fieldsToUnset.reduce((p, it) => {
p[it] = null;
return p;
}, {})
setTimeout(() => {
model.setMultiple(map)
}, 0)
}
/**
@@ -181,7 +320,7 @@ class DynamicLogic {
* @private
*/
processPanel(panel, type) {
const panels = this.defs.panels || {};
const panels = this.defs.panels ?? {};
const item = (panels[panel] || {});
if (!(type in item)) {
@@ -564,8 +703,8 @@ class DynamicLogic {
* @param {Object} item Condition definitions.
*/
addPanelVisibleCondition(name, item) {
this.defs.panels = this.defs.panels || {};
this.defs.panels[name] = this.defs.panels[name] || {};
this.defs.panels = this.defs.panels ?? {};
this.defs.panels[name] = this.defs.panels[name] ?? {};
this.defs.panels[name].visible = item;
@@ -579,8 +718,8 @@ class DynamicLogic {
* @param {Object} item Condition definitions.
*/
addPanelStyledCondition(name, item) {
this.defs.panels = this.defs.panels || {};
this.defs.panels[name] = this.defs.panels[name] || {};
this.defs.panels = this.defs.panels ?? {};
this.defs.panels[name] = this.defs.panels[name] ?? {};
this.defs.panels[name].styled = item;
+349
View File
@@ -0,0 +1,349 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* 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 "EspoCRM" word.
************************************************************************/
import {inject} from 'di';
import Metadata from 'metadata';
// noinspection JSUnusedGlobalSymbols
export default class CascadeLinksHelper {
/**
* @type {Metadata}
* @private}
*/
@inject(Metadata)
metadata
/**
* @param {{
* model: import('model').default,
* foreignEntityType: string,
* items: {
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }[],
* }} options
*/
constructor(options) {
this.options = options;
this.model = options.model;
}
/**
* @private
* @param {
* {
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }
* } item
* @param {string|null} targetEntityType
* @return {{id: string, entityType: string, name: string}[]|null}
*/
prepareItemEntries(item, targetEntityType) {
const field = item.localField;
const foreignField = item.foreignField;
if (!field || !foreignField) {
console.warn("Bad cascading fields definition.");
return null;
}
const type = this.model.getFieldType(field);
let entityType = this.model.getLinkParam(field, 'entity');
/** @type {{id: string, entityType: string, name: string}[]} */
const entries = [];
if (type === 'linkParent') {
entityType = this.model.get(field + 'Type');
}
if (!entityType) {
return null;
}
if (targetEntityType !== entityType) {
return null;
}
if (type === 'link' || type === 'linkParent' || type === 'linkOne') {
const id = this.model.get(field + 'Id');
const name = this.model.get(field + 'Name');
if (!id) {
return null;
}
entries.push({id, entityType, name});
} else if (type === 'linkMultiple') {
/** @type {string[]} */
const ids = this.model.get(field + 'Ids') ?? [];
const names = this.model.get(field + 'Names') ?? {};
entries.push(
...ids.map(id => ({id, entityType, name: names[id]}))
);
} else {
return null;
}
return entries;
}
/**
* @return {Object.<string, module:search-manager~advancedFilter>}
*/
prepareFilters() {
/**
* @param {{localField: string, foreignField: string, matchRequired: boolean}} item
* @return {Object.<string, module:search-manager~advancedFilter>|null}
*/
const prepareItem = (item) => {
const field = item.localField;
const foreignField = item.foreignField;
if (!field || !foreignField) {
console.warn("Bad cascading fields definition.");
return null;
}
const linkEntityType = this.getLinkEntityType(foreignField);
const foreignType = this.getForeignType(foreignField);
const entries = this.prepareItemEntries(item, linkEntityType);
if (entries === null || !entries.length) {
return null;
}
if (foreignType === 'link' || foreignType === 'linkOne') {
if (entries.length > 1) {
return {
[foreignField]: {
type: 'in',
attribute: foreignField + 'Id',
value: entries.map(it => it.id),
data: {
type: 'isOneOf',
oneOfIdList: entries.map(it => it.id),
oneOfNameHash: entries.reduce((p, it) => {
p[it.id] = it.name;
return p;
}, {}),
},
}
};
}
return {
[foreignField]: {
type: 'equals',
attribute: foreignField + 'Id',
value: entries[0].id,
data: {
type: 'equals',
idValue: entries[0].id,
nameValue: entries[0].name,
}
}
};
}
if (foreignType === 'linkMultiple') {
return {
[foreignField]: {
type: 'linkedWithAll',
attribute: foreignField,
value: entries.map(it => it.id),
data: {
type: 'allOf',
nameHash: entries.reduce((p, it) => {
p[it.id] = it.name;
return p;
}, {}),
},
}
};
}
// Not supported.
if (foreignType === 'linkParent') {
if (entries.length > 1) {
console.warn("Cascading fields do not support multiple matches for link-parent filters.");
return null;
}
return {
[foreignField]: {
type: 'and',
attribute: foreignField + 'Id',
value: [
{
type: 'equals',
field: foreignField + 'Id',
value: entries[0].id,
},
{
type: 'equals',
field: foreignField + 'Type',
value: entries[0].entityType,
}
],
data: {
type: 'is',
idValue: entries[0].id,
nameValue: entries[0].name,
typeValue: entries[0].entityType,
}
}
};
}
return null;
};
const output = {};
for (const item of this.options.items) {
const itemOutput = prepareItem(item);
if (itemOutput === null && item.matchRequired) {
return {
id: {
type: 'isNull',
attribute: 'id',
data: {
type: 'isEmpty',
},
},
};
}
Object.assign(output, itemOutput);
}
return output;
}
/**
* @return {Object.<string, *>}
*/
prepareCreateAttributes() {
const attributes = {};
/**
* @param {{localField: string, foreignField: string, matchRequired: boolean}} item
* @return {Object.<string, *>}
*/
const prepareItem = (item) => {
const field = item.localField;
const foreignField = item.foreignField;
if (!field || !foreignField) {
console.warn("Bad cascading fields definition.");
return null;
}
const linkEntityType = this.getLinkEntityType(foreignField);
const foreignType = this.getForeignType(foreignField);
const entries = this.prepareItemEntries(item, linkEntityType);
if (entries === null || !entries.length) {
return null;
}
if (foreignType === 'link' || foreignType === 'linkOne') {
return {
[foreignField + 'Id']: entries[0].id,
[foreignField + 'Name']: entries[0].name,
};
}
if (foreignType === 'linkMultiple') {
return {
[foreignField + 'Ids']: entries.map(it => it.id),
[foreignField + 'Names']: entries.reduce((p, it) => {
p[it.id] = it.name;
return p;
}, {}),
}
}
// Not supported.
if (foreignType === 'linkParent') {
return {
[foreignField + 'Id']: entries[0].id,
[foreignField + 'Name']: entries[0].name,
[foreignField + 'Type']: entries[0].entityType,
};
}
};
for (const item of this.options.items) {
const itemOutput = prepareItem(item);
if (itemOutput === null && item.matchRequired) {
continue;
}
Object.assign(attributes, itemOutput);
}
return attributes;
}
/**
* @private
* @param {string} foreignField
* @return {string|null}
*/
getLinkEntityType(foreignField) {
return this.metadata.get(`entityDefs.${this.options.foreignEntityType}.links.${foreignField}.entity`);
}
/**
* @private
* @param {string} foreignField
* @return {string|null}
*/
getForeignType(foreignField) {
return this.metadata.get(`entityDefs.${this.options.foreignEntityType}.fields.${foreignField}.type`);
}
}
@@ -596,6 +596,25 @@ class FieldManagerEditView extends View {
this.hasDynamicLogicPanel = true;
}
if (!defs.dynamicLogicCascadingDisabled && ['link', 'linkOne', 'linkMultiple'].includes(this.type)) {
const foreignScope = this.getMetadata().get(`entityDefs.${this.scope}.links.${this.field}.entity`);
if (foreignScope) {
const value = this.getMetadata().get(['logicDefs', this.scope, 'cascadingFields', this.field]);
this.model.set('dynamicLogicCascading', value);
promiseList.push(
this.createFieldView(null, 'dynamicLogicCascading', null, {
view: 'views/admin/field-manager/fields/dynamic-logic-cascading',
scope: this.scope,
field: this.field,
foreignScope: foreignScope,
})
);
}
}
return Promise.all(promiseList);
}
@@ -0,0 +1,581 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* 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 "EspoCRM" word.
************************************************************************/
import BaseFieldView from 'views/fields/base';
import View from 'view';
import Model from 'model';
import Collection from 'collection';
import ModalView from 'views/modal';
import EditForModalRecordView from 'views/record/edit-for-modal';
import EnumFieldView from 'views/fields/enum';
import BoolFieldView from 'views/fields/bool';
// noinspection JSUnusedGlobalSymbols
export default class DynamicLogicCascadingFieldView extends BaseFieldView {
// language=Handlebars
detailTemplateContent = `
{{#if ids.length}}
<table class="table" data-role="cascading-items-list">
{{#each ids}}
<tr data-id="{{id}}"><td>{{{lookup ../this this}}}</td></div>
{{/each}}
</table>
{{/if}}
{{#unless ids.length}}
{{#if isSet}}
<span class="none-value">{{translate 'None'}}</span>
{{else}}
<span class="loading-value"></span>
{{/if}}
{{/unless}}
`
// language=Handlebars
editTemplateContent = `
<style>
table[data-role="cascading-items-list"] {
border-top: 0;
> thead > tr > th {
border-top: 0;
}
font-size: var(--13px);
}
</style>
{{#if ids.length}}
<table class="table" data-role="cascading-items-list">
<thead>
<tr>
<th>
<div class="row small text-muted">
<div class="col-md-5">{{localFieldLabel}}</div>
<div class="col-md-5">{{foreignFieldLabel}}</div>
</div>
</th>
</tr>
</thead>
{{#each ids}}
<tr data-id="{{id}}"><td>{{{lookup ../this this}}}</td></div>
{{/each}}
</table>
{{/if}}
<div>
<button
class="btn btn-link btn-icon"
data-action="addRow"
title="{{translate 'Add'}}"
><span class="fas fa-plus"></span></button>
</div>
`
/**
* @private
* @type {import('collection').default}
*/
itemCollection
/**
* @private
* @type {ItemView[]}
*/
itemViews
/**
* @private
* @type {string}
*/
foreignScope
data() {
return {
isSet: this.subModel.has('items'),
ids: this.itemCollection.models.map(m => m.id),
localFieldLabel: this.translate('localField', 'fields', 'DynamicLogic'),
foreignFieldLabel: this.translate('foreignField', 'fields', 'DynamicLogic'),
};
}
/**
* Prevents the change event from firing on sub-field change.
*/
initElement() {}
setup() {
this.addActionHandler('addRow', () => this.addItem());
this.subModel = new Model();
this.foreignScope = this.params.foreignScope;
const syncModels = () => {
const value = this.model.attributes[this.name];
if (value !== undefined) {
const items = value?.items ?? [];
this.subModel.set('items', Espo.Utils.cloneDeep(items));
} else {
this.subModel.unset('items');
}
};
syncModels();
this.listenTo(this.model, 'change:' + this.name, (m, v, o) => {
if (o.fromView !== this) {
syncModels();
}
this.listenTo(this.subModel, 'change', (m, o) => {
if (o.ui) {
this.trigger('change');
}
});
});
}
async prepare() {
this.destroyItemViews();
this.itemCollection = new Collection();
const items = this.getItemsFromModel();
if (items === undefined) {
return;
}
let mode = this.mode;
if (mode !== 'detail' && mode !== 'edit') {
mode = 'detail';
}
const promiseList = [];
this.itemViews = [];
for (const [i, item] of items.entries()) {
const model = new Model({...item, id: i.toString()});
this.listenTo(model, 'change', (m, /** Record */o) => {
if (o.ui) {
this.model.setMultiple({[this.name]: {items: this.getItemsFromCollection()}}, {ui: true});
}
});
this.itemCollection.push(model);
const view = new ItemView({
model: model,
mode: mode,
onRemove: () => this.removeRow(i),
scope: this.params.scope,
foreignScope: this.foreignScope,
});
this.itemViews.push(view);
const promise = this.assignView(view.model.id, view, `[data-id="${view.model.id}"]`);
promiseList.push(promise);
}
await Promise.all(promiseList);
}
/**
* @private
*/
destroyItemViews() {
this.itemViews = [];
if (this.itemCollection) {
this.itemCollection.models.forEach(model => this.clearView(model.id));
}
}
/**
* @private
* @return {{
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }[]|undefined}
*/
getItemsFromModel() {
return Espo.Utils.cloneDeep(this.model.attributes?.[this.name]?.items ?? undefined);
}
/**
* @private
* @return {{
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }[]}
*/
getItemsFromCollection() {
return this.itemCollection.models.map(item => {
return {
localField: item.attributes.localField,
foreignField: item.attributes.foreignField,
matchRequired: item.attributes.matchRequired,
};
});
}
/**
* @private
*/
async addItem() {
const view = new AddItemView({
scope: this.params.scope,
foreignScope: this.foreignScope,
onApply: item => this.addRow(item),
});
await this.assignView('modal', view);
await view.render();
}
/**
* @private
* @param {{
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }} item
*/
async addRow(item) {
const items = this.getItemsFromModel() ?? [];
items.push(Espo.Utils.cloneDeep(item));
this.model.setMultiple({[this.name]: {items}}, {ui: true});
await this.prepare();
await this.reRender();
}
/**
* @private
* @param {number} index
*/
async removeRow(index) {
const items = this.getItemsFromModel() || [];
items.splice(index, 1);
this.model.setMultiple({[this.name]: {items}}, {ui: true});
await this.prepare();
await this.reRender();
}
fetch() {
if (!this.itemCollection) {
return {[this.name]: null};
}
const items = this.getItemsFromCollection().map(item => {
return {
localField: item.localField,
foreignField: item.foreignField,
matchRequired: item.matchRequired,
};
});
if (!items.length) {
return {[this.name]: null};
}
return {[this.name]: {items}};
}
}
class ItemView extends View {
// language=Handlebars
templateContent = `
<style></style>
<div class="row">
<div class=" {{columnClassName}} ">{{localField}}</div>
<div class=" {{columnClassName}} ">{{foreignField}}</div>
<div class="col-md-1 ">{{#if matchRequired}}*{{/if}}</div>
{{#if isEditMode}}
<div class="col-md-1" style="text-align: center;">
<a
role="button"
data-action="removeRow"
class="pull-right"
title="{{translate 'Remove'}}"
><span class="fas fa-times"></span></a>
</div>
{{/if}}
</div>
`
/**
* @type {import('model').default}
*/
model
/**
* @type {'detail'|'edit'}
*/
mode
/**
* @param {{
* model: import('model').default,
* mode: 'detail'|'edit',
* onRemove: function(),
* scope: string,
* foreignScope: string,
* }} options
*/
constructor(options) {
super();
this.model = options.model;
this.mode = options.mode;
this.options = options;
}
data() {
return {
isEditMode: this.mode === 'edit',
columnClassName: this.mode === 'edit' ? 'col-md-5' : 'col-md-5',
localField: this.translate(this.model.attributes.localField, 'fields', this.options.scope),
foreignField: this.translate(this.model.attributes.foreignField, 'fields', this.options.foreignScope),
matchRequired: this.model.attributes.matchRequired,
};
}
setup() {
this.addActionHandler('removeRow', () => this.options.onRemove());
}
}
class AddItemView extends ModalView {
templateContent = `
<div class="record no-side-margin">{{{record}}}</div>
`
/**
* @param {{
* scope: string,
* foreignScope: string,
* onApply: function({
* localField: string,
* foreignField: string,
* matchRequired: boolean,
* }),
* }} options
*/
constructor(options) {
super(options);
this.options = options;
}
setup() {
super.setup();
this.headerText = this.translate('Add');
this.buttonList = [
{
name: 'apply',
label: 'Apply',
style: 'primary',
onClick: () => apply(),
},
{
name: 'cancel',
label: 'Cancel',
onClick: () => this.close(),
}
];
const model = new Model({
localField: null,
foreignField: null,
matchRequired: false,
});
const scope = this.options.scope;
const foreignScope = this.options.foreignScope;
const localFieldDefs = this.getMetadata().get(`entityDefs.${scope}.fields`) ?? {};
const foreignFieldDefs = this.getMetadata().get(`entityDefs.${foreignScope}.fields`) ?? {};
const localFields = Object.keys(localFieldDefs)
.filter(field => {
/** @type {Record} */
const defs = localFieldDefs[field];
if (defs.utility || defs.disabled) {
return false;
}
if (!['link', 'linkParent', 'linkOne', 'linkMultiple'].includes(defs.type)) {
return false;
}
return true;
});
const foreignFields = Object.keys(foreignFieldDefs)
.filter(field => {
/** @type {Record} */
const defs = foreignFieldDefs[field];
if (defs.utility || defs.disabled) {
return false;
}
if (!['link', 'linkOne', 'linkMultiple'].includes(defs.type)) {
return false;
}
return true;
});
localFields.unshift('');
foreignFields.unshift('');
const apply = () => {
if (recordView.validate()) {
return;
}
this.options.onApply({
localField: model.attributes.localField,
foreignField: model.attributes.foreignField,
matchRequired: model.attributes.matchRequired,
});
this.close();
}
const recordView = new EditForModalRecordView({
model,
detailLayout: [
{
rows: [
[
{
view: new EnumFieldView({
name: 'localField',
params: {
required: true,
options: localFields,
isSorted: true,
},
translatedOptions: localFields.reduce((o, it) => {
o[it] = this.translate(it, 'fields', this.options.scope);
return o;
}, {}),
labelText: this.translate('localField', 'fields', 'DynamicLogic'),
})
},
{
view: new EnumFieldView({
name: 'foreignField',
params: {
required: true,
},
translatedOptions: foreignFields.reduce((o, it) => {
o[it] = this.translate(it, 'fields', this.options.foreignScope);
return o;
}, {}),
labelText: this.translate('foreignField', 'fields', 'DynamicLogic'),
})
},
],
[
{
view: new BoolFieldView({
name: 'matchRequired',
labelText: this.translate('matchRequired', 'fields', 'DynamicLogic'),
})
},
false
]
]
}
],
});
this.assignView('record', recordView);
this.listenTo(model, 'change:localField', (m, v, /** Record */ o) => {
if (!o.ui) {
return;
}
const localField = model.attributes.localField;
const fields = foreignFields.filter(it => {
if (it === '') {
return true;
}
if (!localField) {
return false;
}
const type = this.getMetadata().get(`entityDefs.${scope}.fields.${localField}.type`);
const entityType = this.getMetadata().get(`entityDefs.${scope}.links.${localField}.entity`);
const foreignEntityType = this.getMetadata().get(`entityDefs.${foreignScope}.links.${it}.entity`);
if (type === 'linkParent') {
return true;
}
return entityType === foreignEntityType;
});
recordView.setFieldOptionList('foreignField', fields);
setTimeout(() => {
model.set('foreignField', null);
}, 0)
});
}
}
+1
View File
@@ -33,6 +33,7 @@ class IdFieldView extends VarcharFieldView {
searchTypeList = [
'equals',
'notEquals',
'isEmpty',
]
}
+91 -67
View File
@@ -31,6 +31,7 @@
import BaseFieldView from 'views/fields/base';
import RecordModal from 'helpers/record-modal';
import Autocomplete from 'ui/autocomplete';
import CascadeLinksHelper from 'helpers/field/cascade-links';
/**
* A link-multiple field (for has-many relations).
@@ -47,6 +48,9 @@ class LinkMultipleFieldView extends BaseFieldView {
* Record
* } [params] Parameters.
* @property {boolean} [createDisabled] Disable create button in the select modal.
* @property {{
* items: {localField: string, foreignField: string, matchRequired: boolean}[]
* }} [cascadingLogic] Cascading fields logic. As of 9.4.0.
*/
/**
@@ -356,7 +360,10 @@ class LinkMultipleFieldView extends BaseFieldView {
Object.keys(attributeMap).forEach(attr => attributes[attributeMap[attr]] = this.model.get(attr));
return attributes;
return {
...attributes,
...this._getCascadingCreateAttributes(),
};
}
/** @inheritDoc */
@@ -525,70 +532,39 @@ class LinkMultipleFieldView extends BaseFieldView {
} :
{};
if (this.panelDefs.selectHandler) {
return new Promise(resolve => {
this._getSelectFilters().then(filters => {
if (filters.bool) {
url += '&' + $.param({boolFilterList: filters.bool});
}
if (filters.primary) {
url += '&' + $.param({primaryFilter: filters.primary});
}
return new Promise(async resolve => {
const filters = await this._getSelectFilters();
const advanced = {
...notSelectedFilter,
...(filters.advanced || {}),
};
if (filters.bool) {
url += '&' + $.param({boolFilterList: filters.bool});
}
if (Object.keys(advanced).length) {
url += '&' + $.param({where: advanced});
}
if (filters.primary) {
url += '&' + $.param({primaryFilter: filters.primary});
}
const orderBy = filters.orderBy || this.panelDefs.selectOrderBy;
const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection;
const advanced = {
...notSelectedFilter,
...(filters.advanced ?? {}),
};
if (orderBy) {
url += '&' + $.param({
orderBy: orderBy,
order: orderDirection || 'asc',
});
}
if (Object.keys(advanced).length) {
url += '&' + $.param({where: advanced});
}
resolve(url);
const orderBy = filters.orderBy || this.panelDefs.selectOrderBy;
const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection;
if (orderBy) {
url += '&' + $.param({
orderBy: orderBy,
order: orderDirection || 'asc',
});
});
}
}
const boolList = [
...(this.getSelectBoolFilterList() || []),
...(this.panelDefs.selectBoolFilterList || []),
];
if (boolList.length) {
url += '&' + $.param({'boolFilterList': boolList});
}
const primary = this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName;
if (primary) {
url += '&' + $.param({'primaryFilter': primary});
}
if (Object.keys(notSelectedFilter).length) {
url += '&' + $.param({'where': notSelectedFilter});
}
if (this.panelDefs.selectOrderBy) {
const direction = this.panelDefs.selectOrderDirection || 'asc';
url += '&' + $.param({
orderBy: this.panelDefs.selectOrderBy,
order: direction,
});
}
return url;
resolve(url);
});
}
/** @inheritDoc */
@@ -1150,7 +1126,7 @@ class LinkMultipleFieldView extends BaseFieldView {
*/
getCreateAttributesProvider() {
return () => {
const attributes = this.getCreateAttributes() || {};
const attributes = this.getCreateAttributes() ?? {};
if (!this.panelDefs.createHandler) {
return Promise.resolve(attributes);
@@ -1203,34 +1179,44 @@ class LinkMultipleFieldView extends BaseFieldView {
if (!handler || this.isSearchMode()) {
const boolFilterList = (localBoolFilterList || this.panelDefs.selectBoolFilterList) ?
[
...(localBoolFilterList || []),
...(this.panelDefs.selectBoolFilterList || []),
...(localBoolFilterList ?? []),
...(this.panelDefs.selectBoolFilterList ?? []),
] :
undefined;
const advanced = {
...(this.getSelectFilters() ?? {}),
...(!this.isSearchMode() ? this._getCascadingFilters() : {}),
};
return Promise.resolve({
primary: this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName,
primary: this.getSelectPrimaryFilterName() ?? this.panelDefs.selectPrimaryFilterName,
bool: boolFilterList,
advanced: this.getSelectFilters() || undefined,
advanced: advanced,
});
}
return new Promise(resolve => {
return new Promise(async resolve => {
Espo.loader.requirePromise(handler)
.then(Handler => new Handler(this.getHelper()))
.then(/** module:handlers/select-related */handler => {
return handler.getFilters(this.model);
})
.then(filters => {
const advanced = {...(this.getSelectFilters() || {}), ...(filters.advanced || {})};
const advanced = {
...(this.getSelectFilters() || {}),
...(filters.advanced || {}),
...this._getCascadingFilters(),
};
const primaryFilter = this.getSelectPrimaryFilterName() ||
filters.primary || this.panelDefs.selectPrimaryFilterName;
const boolFilterList = (localBoolFilterList || filters.bool || this.panelDefs.selectBoolFilterList) ?
[
...(localBoolFilterList || []),
...(filters.bool || []),
...(this.panelDefs.selectBoolFilterList || []),
...(localBoolFilterList ?? []),
...(filters.bool ?? []),
...(this.panelDefs.selectBoolFilterList ?? []),
] :
undefined;
@@ -1285,6 +1271,44 @@ class LinkMultipleFieldView extends BaseFieldView {
getOnEmptyAutocomplete() {
return undefined;
}
/**
* @private
* @return {CascadeLinksHelper|null}
*/
_createCascadeLinksHelper() {
const items = this.options.cascadingLogic?.items ?? [];
if (!items.length) {
return null;
}
if (!this.foreignScope) {
return null;
}
return new CascadeLinksHelper({
model: this.model,
foreignEntityType: this.foreignScope,
items: items,
});
}
/**
* @private
* @return {Object.<string, module:search-manager~advancedFilter>}
*/
_getCascadingFilters() {
return this._createCascadeLinksHelper()?.prepareFilters() ?? {};
}
/**
* @private
* @return {Object.<string, *>}
*/
_getCascadingCreateAttributes() {
return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {};
}
}
export default LinkMultipleFieldView;
+78 -53
View File
@@ -31,6 +31,7 @@
import BaseFieldView from 'views/fields/base';
import RecordModal from 'helpers/record-modal';
import Autocomplete from 'ui/autocomplete';
import CascadeLinksHelper from 'helpers/field/cascade-links';
/**
* A link field (belongs-to relation).
@@ -47,6 +48,9 @@ class LinkFieldView extends BaseFieldView {
* Record
* } [params] Parameters.
* @property {boolean} [createDisabled] Disable create button in the select modal.
* @property {{
* items: {localField: string, foreignField: string, matchRequired: boolean}[]
* }} [cascadingFieldsLogic] Cascading fields logic. As of 9.4.0.
*/
/**
@@ -355,7 +359,10 @@ class LinkFieldView extends BaseFieldView {
Object.keys(attributeMap).forEach(attr => attributes[attributeMap[attr]] = this.model.get(attr));
return attributes;
return {
...attributes,
...this._getCascadingCreateAttributes(),
};
}
/** @inheritDoc */
@@ -631,61 +638,33 @@ class LinkFieldView extends BaseFieldView {
url += '&select=' + select.join(',');
}
if (this.panelDefs.selectHandler) {
return new Promise(resolve => {
this._getSelectFilters().then(filters => {
if (filters.bool) {
url += '&' + $.param({'boolFilterList': filters.bool});
}
return new Promise(async resolve => {
const filters = await this._getSelectFilters();
if (filters.primary) {
url += '&' + $.param({'primaryFilter': filters.primary});
}
if (filters.bool) {
url += '&' + $.param({'boolFilterList': filters.bool});
}
if (filters.advanced && Object.keys(filters.advanced).length) {
url += '&' + $.param({'where': filters.advanced});
}
if (filters.primary) {
url += '&' + $.param({'primaryFilter': filters.primary});
}
const orderBy = filters.orderBy || this.panelDefs.selectOrderBy;
const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection;
if (filters.advanced && Object.keys(filters.advanced).length) {
url += '&' + $.param({'where': filters.advanced});
}
if (orderBy) {
url += '&' + $.param({
orderBy: orderBy,
order: orderDirection || 'asc',
});
}
const orderBy = filters.orderBy || this.panelDefs.selectOrderBy;
const orderDirection = filters.orderBy ? filters.order : this.panelDefs.selectOrderDirection;
resolve(url);
if (orderBy) {
url += '&' + $.param({
orderBy: orderBy,
order: orderDirection || 'asc',
});
});
}
}
const boolList = [
...(this.getSelectBoolFilterList() || []),
...(this.panelDefs.selectBoolFilterList || []),
];
const primary = this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName;
if (boolList.length) {
url += '&' + $.param({'boolFilterList': boolList});
}
if (primary) {
url += '&' + $.param({'primaryFilter': primary});
}
if (this.panelDefs.selectOrderBy) {
const direction = this.panelDefs.selectOrderDirection || 'asc';
url += '&' + $.param({
orderBy: this.panelDefs.selectOrderBy,
order: direction,
});
}
return url;
resolve(url);
});
}
/** @inheritDoc */
@@ -1139,7 +1118,7 @@ class LinkFieldView extends BaseFieldView {
*/
getCreateAttributesProvider() {
return () => {
const attributes = this.getCreateAttributes() || {};
const attributes = this.getCreateAttributes() ?? {};
if (!this.panelDefs.createHandler) {
return Promise.resolve(attributes);
@@ -1274,12 +1253,15 @@ class LinkFieldView extends BaseFieldView {
] :
undefined;
const advanced = this.getSelectFilters() || {};
const advanced = {
...(this.getSelectFilters() ?? {}),
...(!this.isSearchMode() ? this._getCascadingFilters() : {}),
};
this._applyAdditionalFilter(advanced);
return Promise.resolve({
primary: this.getSelectPrimaryFilterName() || this.panelDefs.selectPrimaryFilterName,
primary: this.getSelectPrimaryFilterName() ?? this.panelDefs.selectPrimaryFilterName,
bool: boolFilterList,
advanced: advanced,
});
@@ -1292,7 +1274,12 @@ class LinkFieldView extends BaseFieldView {
return handler.getFilters(this.model);
})
.then(filters => {
const advanced = {...(this.getSelectFilters() || {}), ...(filters.advanced || {})};
const advanced = {
...(this.getSelectFilters() ?? {}),
...(filters.advanced ?? {}),
...this._getCascadingFilters(),
};
const primaryFilter = this.getSelectPrimaryFilterName() ||
filters.primary || this.panelDefs.selectPrimaryFilterName;
@@ -1320,6 +1307,44 @@ class LinkFieldView extends BaseFieldView {
});
}
/**
* @private
* @return {CascadeLinksHelper|null}
*/
_createCascadeLinksHelper() {
const items = this.options.cascadingLogic?.items ?? [];
if (!items.length) {
return null;
}
if (!this.foreignScope) {
return null;
}
return new CascadeLinksHelper({
model: this.model,
foreignEntityType: this.foreignScope,
items: items,
});
}
/**
* @private
* @return {Object.<string, module:search-manager~advancedFilter>}
*/
_getCascadingFilters() {
return this._createCascadeLinksHelper()?.prepareFilters() ?? {};
}
/**
* @private
* @return {Object.<string, *>}
*/
_getCascadingCreateAttributes() {
return this._createCascadeLinksHelper()?.prepareCreateAttributes() ?? {};
}
actionSelectOneOf() {
Espo.Ui.notifyWait();
+4 -3
View File
@@ -762,7 +762,7 @@ class BaseRecordView extends View {
return;
}
this.processDynamicLogic();
this.processDynamicLogic({action: o.action});
});
this.processDynamicLogic();
@@ -772,9 +772,10 @@ class BaseRecordView extends View {
* Process dynamic logic.
*
* @protected
* @param {{action?: string|'ui'}} [options] Options.
*/
processDynamicLogic() {
this.dynamicLogic.process();
processDynamicLogic(options = {}) {
this.dynamicLogic.process(options);
}
/**
+7 -4
View File
@@ -63,7 +63,7 @@ class DetailRecordView extends BaseRecordView {
* @property {string} [inlineEditDisabled] Disable inline edit.
* @property {boolean} [buttonsDisabled] Disable buttons.
* @property {string} [navigateButtonsDisabled]
* @property {Object} [dynamicLogicDefs]
* @property {module:dynamic-logic~defs} [dynamicLogicDefs]
* @property {module:view-record-helper} [recordHelper] A record helper. For a form state management.
* @property {Object.<string, *>} [attributes]
* @property {module:views/record/detail~button[]} [buttonList] Buttons.
@@ -451,8 +451,7 @@ class DetailRecordView extends BaseRecordView {
* Dynamic logic. Can be overridden by an option parameter.
*
* @protected
* @type {Object}
* @todo Add typedef.
* @type {module:dynamic-logic~defs}
*/
dynamicLogicDefs = {}
@@ -2002,7 +2001,7 @@ class DetailRecordView extends BaseRecordView {
this.navigateButtonsDisabled = this.options.navigateButtonsDisabled ||
this.navigateButtonsDisabled;
this.portalLayoutDisabled = this.options.portalLayoutDisabled || this.portalLayoutDisabled;
this.dynamicLogicDefs = this.options.dynamicLogicDefs || this.dynamicLogicDefs;
this.dynamicLogicDefs = this.options.dynamicLogicDefs ?? this.dynamicLogicDefs;
this.accessControlDisabled = this.options.accessControlDisabled || this.accessControlDisabled;
@@ -3273,6 +3272,10 @@ class DetailRecordView extends BaseRecordView {
}
}
if (this.dynamicLogicDefs?.cascadingFields?.[name]) {
o.cascadingLogic = this.dynamicLogicDefs?.cascadingFields?.[name];
}
const cell = {
name: name + 'Field',
view: view,