snapshot of https://github.com/espocrm/espocrm.git (master, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Test / Test on PHP 8.3 (push) Failing after 5s
Test / Test on PHP 8.4 (push) Failing after 4s
Test / Test on PHP 8.5 (push) Failing after 3s
TypeScript static analysis / TypeScript check (push) Failing after 14s
Test Integration on MySQL / Test on PHP 8.3 (push) Failing after 1s
Test Integration on MySQL / Test on PHP 8.5 (push) Failing after 1s
Test Integration on PostgreSQL / Test on PHP 8.4 fix (push) Failing after 1s
Test Integration on PostgreSQL / Test on PHP 8.4 master (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-18 04:19:15 +05:00
commit 7cc7e6b4f3
10077 changed files with 900756 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
/************************************************************************
* 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';
import ViewHelper from 'view-helper';
import AclManager from 'acl-manager';
import Language from 'language';
/** @module helpers/action-item-setup */
/**
* @internal
*/
class ActionItemSetupHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @private
* @type {ViewHelper}
*/
@inject(ViewHelper)
viewHelper
/**
* @private
* @type {AclManager}
*/
@inject(AclManager)
acl
/**
* @private
* @type {Language}
*/
@inject(Language)
language
/**
* @param {{
* view: import('view').default<any>,
* type: string,
* waitFunc: (Promise) => void,
* addFunc: (
* item: import('views/record/detail').DropdownItem |
* import('views/record/detail').Button
* ) => void,
* showFunc: (string) => void,
* hideFunc: (string) => void,
* enableFunc?: (string) => void,
* disableFunc?: (string) => void,
* listenToViewModelSync?: boolean,
* syncEvent?: 'sync'|'change',
* }} options
*/
setup(options) {
const view = options.view;
const type = options.type;
const waitFunc = options.waitFunc;
const addFunc = options.addFunc;
const showFunc = options.showFunc;
const hideFunc = options.hideFunc;
const enableFunc = options.enableFunc ?? (() => {});
const disableFunc = options.disableFunc ?? (() => {});
/** @type {Record[]} */
const actionList = [];
// noinspection JSUnresolvedReference
const scope = view.scope || view.model.entityType;
if (!scope) {
throw new Error();
}
const path = type.split('.');
/** @type {({name?: string} & Record | string)[]} */
const actionDefsListOriginal = [
...this.metadata.get(['clientDefs', 'Global', ...path], []),
...this.metadata.get(['clientDefs', scope, ...path], []),
];
/** @type {({name?: string} & Record<string, any>)[]} */
let actionDefsList = actionDefsListOriginal.map(item => {
if (typeof item === 'string') {
return {name: item};
}
return Espo.Utils.cloneDeep(item);
})
actionDefsList.reverse();
actionDefsList = actionDefsList.filter((it, i, self) => {
return self.findIndex(sIt => sIt.name === it.name) === i;
});
actionDefsList.reverse();
actionDefsList.forEach(item => {
const name = item.name;
if (!item.label && !item.labelTranslation && !item.iconClass) {
item.text = this.language.translate(name, 'actions', scope);
}
item.data = item.data || {};
const handlerName = item.handler || item.data.handler;
if (handlerName && !item.data.handler) {
item.data.handler = handlerName;
}
if (!Espo.Utils.checkActionAvailability(this.viewHelper, item)) {
return;
}
addFunc(item);
if (!Espo.Utils.checkActionAccess(this.acl, view.model, item, true)) {
item.hidden = true;
}
actionList.push(item);
if (!handlerName) {
return;
}
if (!item.initFunction && !item.checkVisibilityFunction && !item.checkAvailabilityFunction) {
return;
}
waitFunc(new Promise(resolve => {
Espo.loader.require(handlerName, Handler => {
const handler = new Handler(view);
if (item.initFunction) {
handler[item.initFunction].call(handler);
}
if (item.checkVisibilityFunction) {
const isNotVisible = !handler[item.checkVisibilityFunction].call(handler);
if (isNotVisible) {
hideFunc(item.name);
}
}
if (item?.checkAvailabilityFunction) {
const isNotAvailable = !handler[item.checkAvailabilityFunction].call(handler);
if (isNotAvailable) {
disableFunc(item.name);
}
}
item.handlerInstance = handler;
resolve();
});
}));
});
if (!actionList.length) {
return;
}
const onChange = () => {
actionList.forEach(item => {
const handler = item.handlerInstance;
if (!handler || !item.checkAvailabilityFunction) {
return;
}
const isAvailable = handler[item.checkAvailabilityFunction].call(handler);
isAvailable ?
enableFunc(item.name) :
disableFunc(item.name);
});
}
const onSync = () => {
actionList.forEach(item => {
const handler = item.handlerInstance;
if (handler && item.checkVisibilityFunction) {
const isNotVisible = !handler[item.checkVisibilityFunction].call(handler);
if (isNotVisible) {
hideFunc(item.name);
return;
}
}
if (Espo.Utils.checkActionAccess(this.acl, view.model, item, true)) {
showFunc(item.name);
return;
}
hideFunc(item.name);
});
};
if (options.listenToViewModelSync) {
view.listenTo(view, 'model-sync', () => onSync());
return;
}
view.listenTo(view.model, 'change', () => onChange());
view.listenTo(view.model, options.syncEvent ?? 'sync', () => onSync());
}
}
export default ActionItemSetupHelper;

View File

@@ -0,0 +1,75 @@
/************************************************************************
* 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';
/**
* @since 10.0.0
*/
export default class AssignmentHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @param {string} entityType
* @return {boolean}
*/
hasAssignedUserField(entityType) {
return this.metadata.get(`entityDefs.${entityType}.fields.assignedUser.type`) === 'link' &&
!this.metadata.get(`entityDefs.${entityType}.fields.assignedUser.disabled`) &&
this.metadata.get(`entityDefs.${entityType}.links.assignedUser.entity`) === 'User';
}
/**
* @param {string} entityType
* @return {boolean}
*/
hasAssignedUsersField(entityType) {
return this.metadata.get(`entityDefs.${entityType}.fields.assignedUsers.type`) === 'linkMultiple' &&
this.metadata.get(`entityDefs.${entityType}.links.assignedUsers.entity`) === 'User';
}
/**
* @param {string} entityType
* @return {boolean}
*/
hasCollaboratorsField(entityType) {
if (!this.metadata.get(`scopes.${entityType}.collaborators`)) {
return false;
}
return this.metadata.get(`entityDefs.${entityType}.fields.collaborators.type`) === 'linkMultiple' &&
this.metadata.get(`entityDefs.${entityType}.links.collaborators.entity`) === 'User';
}
}

View File

@@ -0,0 +1,110 @@
/************************************************************************
* 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 Settings from 'models/settings';
import User from 'models/user';
/**
* An export helper.
*/
class ExportHelper {
/**
* @private
* @type {Settings}
*/
@inject(Settings)
config
/**
* @private
* @type {User}
*/
@inject(User)
user
/**
* @param {module:view} view A view.
*/
constructor(view) {
/**
* @private
* @type {module:view}
*/
this.view = view;
}
/**
* Check whether an export should be run in idle.
*
* @param {number} totalCount A total record count.
* @returns {boolean}
*/
checkIsIdle(totalCount) {
if (this.user.isPortal()) {
return false;
}
if (typeof totalCount === 'undefined') {
totalCount = this.view.options.totalCount;
}
return totalCount === -1 || totalCount > this.config.get('exportIdleCountThreshold');
}
/**
* Process export.
*
* @param {string} id An ID.
* @returns {Promise<module:view>} Resolves with a dialog view.
* The view emits the 'close:success' event.
*/
process(id) {
Espo.Ui.notify(false);
return new Promise(resolve => {
this.view.createView('dialog', 'views/export/modals/idle', {id: id})
.then(view => {
view.render();
resolve(view);
this.view.listenToOnce(view, 'success', data => {
resolve(data);
this.view.listenToOnce(view, 'close', () => {
view.trigger('close:success', data);
});
});
});
});
}
}
export default ExportHelper;

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, import('search-manager').AdvancedFilter>}
*/
prepareFilters() {
/**
* @param {{localField: string, foreignField: string, matchRequired: boolean}} item
* @return {Object.<string, import('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, unknown>}
*/
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`);
}
}

View File

@@ -0,0 +1,112 @@
/************************************************************************
* 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.
************************************************************************/
/**
* @since 9.3.1
*/
export default class LinkFieldIconHelper {
/**
* @param {import('views/fields/link').default} view
* @param {{
* iconClass: string,
* getIconClass: function(): string|null,
* getColor: function(): string,
* }} options
*/
constructor(view, options) {
this.view = view;
this.options = options;
view.listenTo(view, 'after:render', () => {
if (view.isEditMode()) {
this.control();
}
});
view.addHandler('keydown', `input[data-name="${view.nameName}"]`, (/** KeyboardEvent */e, target) => {
if (e.code === 'Enter') {
return;
}
target.classList.add('being-typed');
});
view.addHandler('change', `input[data-name="${view.nameName}"]`, (e, target) => {
setTimeout(() => target.classList.remove('being-typed'), 200);
});
view.addHandler('blur', `input[data-name="${view.nameName}"]`, (e, target) => {
target.classList.remove('being-typed');
});
view.on('change', () => {
if (!view.isEditMode()) {
return;
}
const span = view.element.querySelector('span.icon-in-input');
if (span) {
span.parentNode.removeChild(span);
}
setTimeout(() => this.control(), 0);
});
}
/**
* @private
*/
control() {
const view = this.view;
const nameElement = view.element.querySelector(`input[data-name="${view.nameName}"]`);
nameElement.classList.remove('being-typed');
const icon = document.createElement('span');
icon.className = 'icon-in-input ' + this.options.iconClass;
icon.style.color = this.options.getColor();
const iconClass = this.options.getIconClass();
if (!iconClass) {
return;
}
icon.className += ' ' + iconClass;
const input = view.element.querySelector('.input-group > input');
if (!input) {
return;
}
input.after(icon);
}
}

View File

@@ -0,0 +1,106 @@
/************************************************************************
* 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';
import Language from 'language';
/**
* @since 10.0.0
*/
export default class OptionsProvider {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @private
* @type {Language}
*/
@inject(Language)
language
/**
* @param {string} entityType
* @param {string} field
* @return {{
* name: string,
* label: string,
* style: string|null,
* }[]}
*/
get(entityType, field) {
/**
* @type {{
* options?: string[],
* style?: Record<string, string>,
* optionsReference?: string|null,
* optionsPath?: string|null
* }} */
const params = this.metadata.get(`entityDefs.${entityType}.fields.${field}`);
if (!params) {
return [];
}
let sourceEntityType = entityType;
let sourceField = field;
let styleMap = params.style ?? {};
let optionsPath = params.optionsPath;
if (!optionsPath && params.optionsReference) {
const [refEntityType, refField] = params.optionsReference.split('.');
optionsPath = `entityDefs.${refEntityType}.fields.${refField}.options`;
sourceEntityType = refEntityType;
sourceField = refField;
styleMap = this.metadata.get(`entityDefs.${refEntityType}.fields.${refField}.style`) ?? {};
}
let options = params.options;
if (optionsPath) {
options = this.metadata.get(optionsPath);
}
return options.map(it => {
return {
name: it,
label: this.language.translateOption(it, sourceField, sourceEntityType),
style: styleMap[it] ?? null,
}
});
}
}

View File

@@ -0,0 +1,197 @@
/************************************************************************
* 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.
************************************************************************/
/** @module helpers/file-upload */
import {inject} from 'di';
import Settings from 'models/settings';
/**
* A file-upload helper.
*/
class FileUploadExport {
/**
* @private
* @type {Settings}
*/
@inject(Settings)
config
/**
* @typedef {Object} module:helpers/file-upload~Options
*
* @property {function(number):void} [afterChunkUpload] After every chunk is uploaded.
* @property {function(module:model):void} [afterAttachmentSave] After an attachment is saved.
* @property {{isCanceled?: boolean}} [mediator] A mediator.
*/
/**
* Upload.
*
* @param {File} file A file.
* @param {module:model} attachment An attachment model.
* @param {module:helpers/file-upload~Options} [options] Options.
* @returns {Promise}
*/
upload(file, attachment, options) {
options = options || {};
options.afterChunkUpload = options.afterChunkUpload || (() => {});
options.afterAttachmentSave = options.afterAttachmentSave || (() => {});
options.mediator = options.mediator || {};
attachment.set('name', file.name);
attachment.set('type', file.type || 'text/plain');
attachment.set('size', file.size);
if (this._useChunks(file)) {
return this._uploadByChunks(file, attachment, options);
}
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
attachment.set('file', e.target.result);
attachment.save({}, {timeout: 0})
.then(() => resolve())
.catch(() => reject());
};
fileReader.readAsDataURL(file);
});
}
/**
* @private
*/
_uploadByChunks(file, attachment, options) {
return new Promise((resolve, reject) => {
attachment.set('isBeingUploaded', true);
attachment
.save()
.then(() => {
options.afterAttachmentSave(attachment);
return this._uploadChunks(
file,
attachment,
resolve,
reject,
options
);
})
.catch(() => reject());
});
}
/**
* @private
*/
_uploadChunks(file, attachment, resolve, reject, options, start) {
start = start || 0;
let end = start + this._getChunkSize() + 1;
if (end > file.size) {
end = file.size;
}
if (options.mediator.isCanceled) {
reject();
return;
}
const blob = file.slice(start, end);
const fileReader = new FileReader();
fileReader.onloadend = (e) => {
if (e.target.readyState !== FileReader.DONE) {
return;
}
Espo.Ajax
.postRequest('Attachment/chunk/' + attachment.id, e.target.result, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(() => {
options.afterChunkUpload(end);
if (end === file.size) {
resolve();
return;
}
this._uploadChunks(
file,
attachment,
resolve,
reject,
options,
end
);
})
.catch(() => reject());
};
fileReader.readAsDataURL(blob);
}
/**
* @private
*/
_useChunks(file) {
const chunkSize = this._getChunkSize();
if (!chunkSize) {
return false;
}
if (file.size > chunkSize) {
return true;
}
return false;
}
/**
* @private
*/
_getChunkSize() {
return (this.config.get('attachmentUploadChunkSize') || 0) * 1024 * 1024;
}
}
export default FileUploadExport;

View File

@@ -0,0 +1,356 @@
/************************************************************************
* 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 {Draggable} from '@shopify/draggable';
import {inject} from 'di';
import Language from 'language';
/**
* @internal
*/
export default class ListTreeDraggableHelper {
/**
* @type {Language}
*/
@inject(Language)
language
/**
* @private
* @type {boolean}
*/
blockDraggable = false
/**
* @private
* {Draggable}
*/
draggable
/**
* @param {import('views/record/list-tree').default} view
*/
constructor(view) {
this.view = view;
}
destroy() {
if (this.draggable) {
this.draggable.destroy();
}
}
init() {
if (this.draggable) {
this.draggable.destroy();
}
const draggable = this.draggable = new Draggable(this.view.element, {
distance: 8,
draggable: '.list-group-item > .cell > [data-role="moveHandle"]',
mirror: {
cursorOffsetX: 5,
cursorOffsetY: 5,
appendTo: 'body',
},
});
/** @type {HTMLElement[]} */
let rows;
/** @type {Map<HTMLElement, number>} */
let levelMap;
/** @type {HTMLElement|null} */
let movedHandle = null;
/** @type {HTMLElement|null} */
let movedLink = null;
/** @type {HTMLElement|null} */
let movedFromLi = null;
draggable.on('mirror:created', event => {
const mirror = event.mirror;
const source = event.source;
const originalSource = event.originalSource;
originalSource.style.display = '';
source.style.display = 'none';
mirror.style.display = 'block';
mirror.style.cursor = 'grabbing';
mirror.classList.add('draggable-helper', 'draggable-helper-transparent', 'text-info');
mirror.classList.remove('link');
mirror.style.pointerEvents = 'auto';
mirror.removeAttribute('href');
mirror.style.textDecoration = 'none';
mirror.innerText = mirror.dataset.title;
});
draggable.on('mirror:move', event => {
event.mirror.style.pointerEvents = 'auto';
});
draggable.on('drag:start', event => {
if (this.blockDraggable) {
event.cancel();
return;
}
rows = Array.from(this.view.element.querySelectorAll('.list-group-tree > .list-group-item'));
levelMap = new Map();
rows.forEach(row => {
let depth = 0;
let current = row;
while (current && current !== this.view.element) {
current = current.parentElement;
depth ++;
}
levelMap.set(row, depth);
});
rows.sort((a, b) => levelMap.get(b) - levelMap.get(a));
this.view.movedId = event.source.dataset.id;
movedHandle = event.originalSource;
movedFromLi = movedHandle.parentElement.parentElement;
movedLink = movedHandle.parentElement.querySelector(`:scope > a.link`);
movedLink.classList.add('text-info');
});
let overId = null;
let overParentId = null;
let isAfter = false;
let wasOutOfSelf = false;
draggable.on('drag:move', event => {
isAfter = false;
overId = null;
let rowFound = null;
for (const row of rows) {
const rect = row.getBoundingClientRect();
const isIn =
rect.left < event.sensorEvent.clientX &&
rect.right > event.sensorEvent.clientX &&
rect.top < event.sensorEvent.clientY &&
rect.bottom >= event.sensorEvent.clientY;
if (!isIn) {
continue;
}
let itemId = row.dataset.id ?? null;
let itemParentId = null;
if (!itemId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent instanceof HTMLElement) {
// Over a plus row.
itemParentId = parent.dataset.id;
}
}
const itemIsAfter = event.sensorEvent.clientY - rect.top >= rect.bottom - event.sensorEvent.clientY;
if (itemParentId && itemIsAfter) {
continue;
}
if (itemId === this.view.movedId) {
break;
}
if (movedFromLi.contains(row)) {
break;
}
if (!itemId && !itemParentId) {
continue;
}
if (itemParentId) {
const parent = row.closest(`.list-group-item[data-id]`);
if (parent) {
/** @type {NodeListOf<HTMLElement>} */
const items = parent.querySelectorAll(':scope > .children > .list > .list-group > [data-id]');
if (items.length) {
itemId = Array.from(items).pop().dataset.id;
itemParentId = null;
}
}
}
isAfter = itemIsAfter;
overParentId = itemParentId;
overId = itemId;
rowFound = row;
break;
}
for (const row of rows) {
row.classList.remove('border-top-highlighted');
row.classList.remove('border-bottom-highlighted');
}
if (!rowFound) {
return;
}
if (isAfter) {
rowFound.classList.add('border-bottom-highlighted');
rowFound.classList.remove('border-top-highlighted');
} else {
rowFound.classList.add('border-top-highlighted');
rowFound.classList.remove('border-bottom-highlighted');
}
});
draggable.on('drag:stop', async () => {
const finalize = () => {
if (movedLink) {
movedLink.classList.remove('text-info');
}
rows.forEach(row => {
row.classList.remove('border-bottom-highlighted');
row.classList.remove('border-top-highlighted');
});
rows = undefined;
};
let moveType;
let referenceId = overId;
if (overParentId || overId) {
if (overParentId) {
moveType = 'into';
referenceId = overParentId;
} else if (isAfter) {
moveType = 'after';
} else {
moveType = 'before';
}
}
if (moveType) {
this.blockDraggable = true;
const movedId = this.view.movedId;
const affectedId = referenceId;
Espo.Ui.notifyWait();
Espo.Ajax
.postRequest(`${this.view.entityType}/action/move`, {
id: this.view.movedId,
referenceId: referenceId,
type: moveType,
})
.then(async () => {
const promises = [];
if (movedId) {
promises.push(this.updateAfter(this.view, movedId));
}
if (affectedId) {
promises.push(this.updateAfter(this.view, affectedId));
}
await Promise.all(promises);
Espo.Ui.success(this.language.translate('Done'));
})
.finally(() => {
this.blockDraggable = false;
finalize();
});
}
if (!moveType) {
finalize();
}
this.view.movedId = null;
movedHandle = null;
movedFromLi = null;
levelMap = undefined;
overParentId = null;
overId = null;
isAfter = false;
wasOutOfSelf = false;
});
}
/**
* @private
* @param {ListTreeRecordView} view
* @param {string} movedId
* @return {Promise}
*/
async updateAfter(view, movedId) {
if (view.collection.has(movedId)) {
const unfoldedIds = view.getItemViews()
.filter(view => view.isUnfolded && view.model)
.map(view => view.model.id);
await view.collection.fetch({noRebuild: false});
view.getItemViews()
.filter(view => view && view.model && unfoldedIds.includes(view.model.id))
.forEach(view => view.unfold());
return;
}
for (const subView of view.getItemViews()) {
if (!subView.getChildrenView()) {
continue;
}
await this.updateAfter(subView.getChildrenView(), movedId);
}
}
}

View File

@@ -0,0 +1,229 @@
/************************************************************************
* 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 $ from 'jquery';
import {Events} from 'bullbone';
/**
* @internal
*/
class StickyBarHelper {
/** @private */
$bar
/** @private */
$scrollable
/** @private */
$window
/** @private */
$navbarRight
/** @private */
$middle
/** @private */
_isReady = false
/**
* @param {import('views/record/list-base').default} view
* @param {{force?: boolean}} options
*/
constructor(view, options = {}) {
this.view = view;
/**
* @private
* @type {import('theme-manager').default}
*/
this.themeManager = this.view.getThemeManager();
this.$el = view.$el;
/** @private */
this.force = options.force || false;
this.init();
}
init() {
this.$bar = this.$el.find('.sticked-bar');
this.$middle = this.$el.find('> .list');
if (!this.$middle.get(0)) {
return;
}
this.$window = $(window);
this.$scrollable = this.$window;
this.$navbarRight = $('#navbar .navbar-right');
this.isModal = !!this.$el.closest('.modal-body').length;
this.isSmallWindow = $(window.document).width() < this.themeManager.getParam('screenWidthXs');
if (this.isModal) {
this.$scrollable = this.$el.closest('.modal-body');
this.$navbarRight = this.$scrollable.parent().find('.modal-footer');
}
if (!this.force) {
this.$scrollable.off(`scroll.list-${this.view.cid}`);
this.$scrollable.on(`scroll.list-${this.view.cid}`, () => this._controlSticking());
this.$window.off(`resize.list-${this.view.cid}`);
this.$window.on(`resize.list-${this.view.cid}`, () => this._controlSticking());
}
this.listenTo(this.view, 'check', () => {
if (this.view.getCheckedIds().length === 0 && !this.view.isAllResultChecked()) {
return;
}
this._controlSticking();
});
this._isReady = true;
}
_getMiddleTop() {
if (this._middleTop !== undefined && this._middleTop >= 0) {
return this._middleTop;
}
this._middleTop = this._getOffsetTop(this.$middle.get(0));
return this._middleTop;
}
_getButtonsTop() {
if (this._buttonsTop !== undefined && this._buttonsTop >= 0) {
return this._buttonsTop;
}
this._buttonsTop = this._getOffsetTop(this.$el.find('.list-buttons-container').get(0));
return this._buttonsTop;
}
/**
* @private
*/
_controlSticking() {
if (!this.view.toShowStickyBar()) {
return;
}
if (this.isSmallWindow && $('#navbar .navbar-body').hasClass('in')) {
return;
}
const scrollTop = this.$scrollable.scrollTop();
const stickTop = !this.force ? this._getButtonsTop() : 0;
const edge = this._getMiddleTop() + this.$middle.outerHeight(true);
const hide = () => {
this.$bar.addClass('hidden');
this.$navbarRight.removeClass('has-sticked-bar');
};
const show = () => {
this.$bar.removeClass('hidden');
this.$navbarRight.addClass('has-sticked-bar');
};
if (scrollTop >= edge) {
hide();
return;
}
if (scrollTop > stickTop || this.force) {
show();
return;
}
hide();
}
/**
* @private
* @param {HTMLElement} element
*/
_getOffsetTop(element) {
if (!element) {
return 0;
}
const navbarHeight = this.themeManager.getParam('navbarHeight') * this.themeManager.getFontSizeFactor();
const withHeader = !this.isSmallWindow && !this.isModal;
let offsetTop = 0;
do {
if (element.classList.contains('modal-body')) {
break;
}
if (!isNaN(element.offsetTop)) {
offsetTop += element.offsetTop;
}
element = element.offsetParent;
} while (element);
if (withHeader) {
offsetTop -= navbarHeight;
}
if (!this.isModal) {
// padding
offsetTop -= 5;
}
return offsetTop;
}
hide() {
this.$bar.addClass('hidden');
}
destroy() {
this.stopListening(this.view, 'check');
if (!this._isReady) {
return;
}
this.$window.off(`resize.list-${this.view.cid}`);
this.$scrollable.off(`scroll.list-${this.view.cid}`);
}
}
Object.assign(StickyBarHelper.prototype, Events);
export default StickyBarHelper;

View File

@@ -0,0 +1,113 @@
/************************************************************************
* 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 LayoutManager from 'layout-manager';
import Metadata from 'metadata';
import FieldManager from 'field-manager';
class SelectProvider {
/**
* @type {LayoutManager}
* @private
*/
@inject(LayoutManager)
layoutManager
/**
* @type {Metadata}
* @private
*/
@inject(Metadata)
metadata
/**
* @type {FieldManager}
* @private
*/
@inject(FieldManager)
fieldManager
/**
* Get select attributes.
*
* @param {string} entityType
* @param {string} [layoutName='list']
* @return {Promise<string[]>}
*/
get(entityType, layoutName) {
return new Promise(resolve => {
this.layoutManager.get(entityType, layoutName || 'list', layout => {
const list = this.getFromLayout(entityType, layout);
resolve(list);
});
});
}
/**
* Get select attributes from a layout.
*
* @param {string} entityType
* @param {module:views/record/list~columnDefs[]} listLayout
* @param {import('helpers/list/settings').default} [settings]
* @return {string[]}
*/
getFromLayout(entityType, listLayout, settings) {
const list = [];
listLayout.forEach(item => {
if (!item.name) {
return;
}
if (settings?.isColumnHidden(item.name, item.hidden)) {
return;
}
if (!settings && item.hidden) {
return;
}
const field = item.name;
const type = this.metadata.get(`entityDefs.${entityType}.fields.${field}.type`);
if (!type) {
return;
}
list.push(...this.fieldManager.getEntityTypeFieldAttributeList(entityType, field));
});
return list;
}
}
export default SelectProvider;

View File

@@ -0,0 +1,306 @@
/************************************************************************
* 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 Storage from 'storage';
import Utils from 'utils';
/**
* @typedef {Object} ColumnWidth
* @property {number} value A value.
* @property {'px'|'%'} unit A unit.
*/
class ListSettingsHelper {
/**
* @private
* @type {Storage}
*/
@inject(Storage)
storage
/**
* @private
* @type {boolean}
*/
useStorage
/**
* Note: Do not change the signature for the first 3 parameters.
* @internal
*
* @param {string} entityType
* @param {string} key A key used for storage.
* @param {string} userId
* @param {{useStorage?: boolean}} options
*/
constructor(entityType, key, userId, options = {}) {
/** @private */
this.layoutColumnsKey = `${key}-${entityType}-${userId}`;
/**
* @private
* @type {Object.<string, boolean>}
*/
this.hiddenColumnMapCache = undefined;
/**
* @private
* @type {Object.<string, ColumnWidth>}
*/
this.columnWidthMapCache = undefined;
/**
* @private
* @type {boolean|undefined}
*/
this.columnResize = undefined;
/**
* @private
* @type {function()[]}
*/
this.columnWidthChangeFunctions = [];
this.useStorage = options.useStorage ?? true;
}
/**
* @private
* @param {string} key
* @return {*}
*/
getStored(key) {
if (this.useStorage) {
return this.storage.get(key, this.layoutColumnsKey);
}
return null;
}
/**
* @private
* @param {string} key
* @param {*} value
*/
store(key, value) {
if (this.useStorage) {
this.storage.set(key, this.layoutColumnsKey, value);
}
}
/**
* @private
* @param {string} key
*/
clearStored(key) {
if (this.useStorage) {
this.storage.clear(key, this.layoutColumnsKey);
}
}
/**
* Get a stored hidden column map.
*
* @return {Object.<string, boolean>}
*/
getHiddenColumnMap() {
if (this.hiddenColumnMapCache) {
return this.hiddenColumnMapCache;
}
this.hiddenColumnMapCache = /** @type {Object} */
this.getStored('listHiddenColumns') ?? {};
return this.hiddenColumnMapCache;
}
/**
* Is a column hidden.
*
* @param {string} name A name.
* @param {boolean} [hidden] Is hidden by default.
* @return {boolean}
* @since 9.0.0
*/
isColumnHidden(name, hidden) {
const hiddenMap = this.getHiddenColumnMap();
if (hiddenMap[name]) {
return true;
}
if (!hidden) {
return false;
}
if (!(name in hiddenMap)) {
return true;
}
return hiddenMap[name];
}
/**
* Is column resize enabled.
*
* @return {boolean}
* @since 9.0.0
*/
getColumnResize() {
if (this.columnResize === undefined) {
this.columnResize = this.getStored('listColumnResize') ?? false;
}
return this.columnResize;
}
/**
* Store column width editable.
*
* @param {boolean} columnResize
*/
storeColumnResize(columnResize) {
this.columnResize = columnResize;
this.store('listColumnResize', columnResize);
}
// noinspection JSUnusedGlobalSymbols
/**
* Clear column width editable.
*/
clearColumnResize() {
this.columnResize = undefined;
this.clearStored('listColumnResize');
}
/**
* Store a hidden column map.
*
* @param {Object.<string, boolean>} map
*/
storeHiddenColumnMap(map) {
this.hiddenColumnMapCache = Utils.cloneDeep(map);
this.store('listHiddenColumns', map);
}
/**
* Clear a hidden column map in the storage.
*/
clearHiddenColumnMap() {
this.hiddenColumnMapCache = undefined;
this.clearStored('listHiddenColumns');
}
/**
* Get a stored column width map.
*
* @return {Object.<string, ColumnWidth>}
*/
getColumnWidthMap() {
if (this.columnWidthMapCache) {
return this.columnWidthMapCache;
}
this.columnWidthMapCache = /** @type {Object} */
this.getStored('listColumnsWidths') ?? {};
return this.columnWidthMapCache;
}
/**
* Store a column width map.
*
* @param {Object.<string, ColumnWidth>} map
*/
storeColumnWidthMap(map) {
this.columnWidthMapCache = Utils.cloneDeep(map);
this.store('listColumnsWidths', map);
}
/**
* Clear a column width map in the storage.
*/
clearColumnWidthMap() {
this.columnWidthMapCache = undefined;
this.clearStored('listColumnsWidths');
}
/**
* Set a column width.
*
* @param {string} name A column name.
* @param {ColumnWidth} width Width data.
*/
storeColumnWidth(name, width) {
if (!this.columnWidthMapCache) {
this.columnWidthMapCache = {};
}
this.columnWidthMapCache[name] = width;
this.storeColumnWidthMap(this.columnWidthMapCache);
for (const handler of this.columnWidthChangeFunctions) {
handler();
}
}
/**
* Subscribe to a column width change.
*
* @param {function()} handler A handler.
*/
subscribeToColumnWidthChange(handler) {
this.columnWidthChangeFunctions.push(handler);
}
/**
* Unsubscribe from a column width change.
*
* @param {function()} handler A handler.
*/
unsubscribeFromColumnWidthChange(handler) {
const index = this.columnWidthChangeFunctions.findIndex(it => handler === it);
if (!~index) {
return;
}
this.columnWidthChangeFunctions.splice(index, 1);
}
}
export default ListSettingsHelper;

View File

@@ -0,0 +1,142 @@
/************************************************************************
* 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 Settings from 'models/settings';
import User from 'models/user';
import Ui from 'ui';
/**
* A mass-action helper.
*/
class MassActionHelper {
/**
* @private
* @type {Settings}
*/
@inject(Settings)
config
/**
* @private
* @type {User}
*/
@inject(User)
user
/**
* A view.
*
* @private
* @type {import('view').default}}
*/
view
/**
* @private
* @type {string}
*/
entityType
/**
* @param {import('view').default} view A view.
* @param {{entityType?: string}} [options] Options.
*/
constructor(view, options = {}) {
this.view = view;
let entityType = options.entityType;
if (!entityType) {
if ('entityType' in view) {
entityType = view.entityType;
} else if ('scope' in view) {
entityType = view.scope;
}
}
this.entityType = entityType;
}
/**
* Check whether an action should be run in idle.
*
* @param {number} [totalCount] A total record count.
* @returns {boolean}
*/
checkIsIdle(totalCount) {
if (this.user.isPortal()) {
return false;
}
if (typeof totalCount === 'undefined') {
totalCount = this.view.options.totalCount;
}
if (typeof totalCount === 'undefined' && this.view.collection) {
totalCount = this.view.collection.total;
}
return totalCount === -1 || totalCount > this.config.get('massActionIdleCountThreshold');
}
/**
* Process.
*
* @param {string} id An ID.
* @param {string} action An action.
* @returns {Promise<import('view').default>} Resolves with a dialog view.
* The view emits the 'close:success' event.
*/
process(id, action) {
Ui.notify();
return new Promise(async resolve => {
const view = await this.view.createView('dialog', 'views/modals/mass-action', {
id: id,
action: action,
scope: this.entityType,
})
view.render().then(() => {});
resolve(view);
this.view.listenToOnce(view, 'success', data => {
resolve(data);
this.view.listenToOnce(view, 'close', () => {
view.trigger('close:success', data);
});
});
});
}
}
export default MassActionHelper;

View File

@@ -0,0 +1,162 @@
/************************************************************************
* 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';
import ModelFactory from 'model-factory';
/**
* @internal
*/
export default class AttachmentInsertSourceFromHelper {
/**
* @param {import('views/fields/attachment-multiple').default|import('views/fields/file').default} view
*/
constructor(view) {
/** @private */
this.view = view;
/** @private */
this.model = view.model;
}
/**
* @type {Metadata}
* @private
*/
@inject(Metadata)
metadata
/**
* @type {ModelFactory}
* @private
*/
@inject(ModelFactory)
modelFactory
/**
* @param {{
* source: string,
* onInsert: function(import('model').default[]),
* }} params
*/
insert(params) {
const source = params.source;
const viewName =
this.metadata.get(['clientDefs', 'Attachment', 'sourceDefs', source, 'insertModalView']) ||
this.metadata.get(['clientDefs', source, 'modalViews', 'select']) ||
'views/modals/select-records';
let filters = {};
if (('getSelectFilters' + source) in this.view) {
filters = this.view['getSelectFilters' + source]() || {};
}
// @todo EntityType => link mapping defined in metadata for automatic filtering.
if (this.model.attributes.parentId && this.model.attributes.parentType === 'Account') {
if (
this.metadata.get(`entityDefs.${source}.fields.account.type`) === 'link' &&
this.metadata.get(`entityDefs.${source}.links.account.entity`) === 'Account'
) {
filters = {
account: {
type: 'equals',
attribute: 'accountId',
value: this.model.attributes.parentId,
data: {
type: 'is',
idValue: this.model.attributes.parentId,
nameValue: this.model.attributes.parentType,
},
},
...filters,
};
}
}
let boolFilterList = this.metadata.get(`clientDefs.Attachment.sourceDefs.${source}.boolFilterList`);
if (('getSelectBoolFilterList' + source) in this.view) {
boolFilterList = this.view['getSelectBoolFilterList' + source]();
}
let primaryFilterName = this.metadata.get(`clientDefs.Attachment.sourceDefs.${source}.primaryFilter`);
if (('getSelectPrimaryFilterName' + source) in this.view) {
primaryFilterName = this.view['getSelectPrimaryFilterName' + source]();
}
/** @type {module:views/modals/select-records~Options} */
const options = {
entityType: source,
createButton: false,
filters: filters,
boolFilterList: boolFilterList,
primaryFilterName: primaryFilterName,
multiple: true,
onSelect: models => {
models.forEach(async model => {
if (model.entityType === 'Attachment') {
params.onInsert([model]);
return;
}
/** @type {Record[]} */
const attachmentDataList = await Espo.Ajax.postRequest(`${source}/action/getAttachmentList`, {
id: model.id,
field: this.view.name,
parentType: this.view.entityType,
});
const attachmentSeed = await this.modelFactory.create('Attachment');
for (const item of attachmentDataList) {
const attachment = attachmentSeed.clone();
attachment.set(item);
params.onInsert([attachment]);
}
});
},
};
Espo.Ui.notifyWait();
this.view.createView('modal', viewName, options, view => {
view.render();
Espo.Ui.notify();
});
}
}

View File

@@ -0,0 +1,240 @@
/************************************************************************
* 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.
************************************************************************/
/** @module module:helpers/misc/authentication-provider */
export default class {
/**
* @param {module:views/record/detail} view A view.
*/
constructor(view) {
/**
* @private
* @type {module:views/record/detail}
*/
this.view = view;
this.metadata = view.getMetadata();
/**
* @private
* @type {module:model}
*/
this.model = view.model;
/** @var {Object.<string, Object.<string, *>>} defs */
const defs = view.getMetadata().get(['authenticationMethods']) || {};
/**
* @private
* @type {string[]}
*/
this.methodList = Object.keys(defs).filter(item => {
/** @var {Object.<string, *>} */
const data = defs[item].provider || {};
return data.isAvailable;
});
/** @private */
this.authFields = {};
/** @private */
this.dynamicLogicDefs = {
fields: {},
panels: {},
};
}
/**
* @param {function(): void} callback
*/
setupPanelsVisibility(callback) {
this.handlePanelsVisibility(callback);
this.view.listenTo(this.model, 'change:method', () => this.handlePanelsVisibility(callback));
}
/**
* @private
* @param {string} method
* @param {string} param
* @return {*}
*/
getFromMetadata(method, param) {
return this.metadata
.get(['authenticationMethods', method, 'provider', param]) ||
this.metadata
.get(['authenticationMethods', method, 'settings', param]);
}
/**
* @return {Object}
*/
setupMethods() {
this.methodList.forEach(method => this.setupMethod(method));
return this.dynamicLogicDefs;
}
/**
* @private
*/
setupMethod(method) {
/** @var {string[]} */
let fieldList = this.getFromMetadata(method, 'fieldList') || [];
fieldList = fieldList.filter(item => this.model.hasField(item));
this.authFields[method] = fieldList;
const mDynamicLogicFieldsDefs = (this.getFromMetadata(method, 'dynamicLogic') || {}).fields || {};
for (const f in mDynamicLogicFieldsDefs) {
if (!fieldList.includes(f)) {
continue;
}
const defs = this.modifyDynamicLogic(mDynamicLogicFieldsDefs[f]);
this.dynamicLogicDefs.fields[f] = Espo.Utils.cloneDeep(defs);
}
}
/**
* @private
*/
modifyDynamicLogic(defs) {
defs = Espo.Utils.clone(defs);
if (Array.isArray(defs)) {
return defs.map(item => this.modifyDynamicLogic(item));
}
if (typeof defs === 'object') {
const o = {};
for (const property in defs) {
let value = defs[property];
if (property === 'attribute' && value === 'authenticationMethod') {
value = 'method';
}
o[property] = this.modifyDynamicLogic(value);
}
return o;
}
return defs;
}
modifyDetailLayout(layout) {
this.methodList.forEach(method => {
let mLayout = this.getFromMetadata(method, 'layout');
if (!mLayout) {
return;
}
mLayout = Espo.Utils.cloneDeep(mLayout);
mLayout.name = method;
this.prepareLayout(mLayout, method);
layout.push(mLayout);
});
}
prepareLayout(layout, method) {
layout.rows.forEach(row => {
row
.filter(item => !item.noLabel && !item.labelText && item.name)
.forEach(item => {
if (item === null) {
return;
}
const labelText = this.view.translate(item.name, 'fields', 'Settings');
item.options = item.options || {};
if (labelText && labelText.toLowerCase().indexOf(method.toLowerCase() + ' ') === 0) {
item.labelText = labelText.substring(method.length + 1);
}
item.options.tooltipText = this.view.translate(item.name, 'tooltips', 'Settings');
});
});
layout.rows = layout.rows.map(row => {
row = row.map(cell => {
if (
cell &&
cell.name &&
!this.model.hasField(cell.name)
) {
return false;
}
return cell;
})
return row;
});
}
/**
* @private
* @param {function(): void} callback
*/
handlePanelsVisibility(callback) {
const authenticationMethod = this.model.get('method');
this.methodList.forEach(method => {
const fieldList = (this.authFields[method] || []);
if (method !== authenticationMethod) {
this.view.hidePanel(method);
fieldList.forEach(field => {
this.view.hideField(field);
});
return;
}
this.view.showPanel(method);
fieldList.forEach(field => this.view.showField(field));
callback();
});
}
}

View File

@@ -0,0 +1,118 @@
/************************************************************************
* 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.
************************************************************************/
/** @module helpers/misc/field-language */
/**
* A field-language util.
*/
class FieldLanguage {
/**
* @param {module:metadata} metadata A metadata.
* @param {module:language} language A language.
*/
constructor(metadata, language) {
/**
* @private
* @type {module:metadata}
*/
this.metadata = metadata;
/**
* @private
* @type {module:language}
*/
this.language = language;
}
/**
* Translate an attribute.
*
* @param {string} scope A scope.
* @param {string} name An attribute name.
* @returns {string}
*/
translateAttribute(scope, name) {
let label = this.language.translate(name, 'fields', scope);
if (name.indexOf('Id') === name.length - 2) {
const baseField = name.slice(0, name.length - 2);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('id', 'fields') + ')';
}
}
else if (name.indexOf('Name') === name.length - 4) {
const baseField = name.slice(0, name.length - 4);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('name', 'fields') + ')';
}
}
else if (name.indexOf('Type') === name.length - 4) {
const baseField = name.slice(0, name.length - 4);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('type', 'fields') + ')';
}
}
if (name.indexOf('Ids') === name.length - 3) {
const baseField = name.slice(0, name.length - 3);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('ids', 'fields') + ')';
}
}
else if (name.indexOf('Names') === name.length - 5) {
const baseField = name.slice(0, name.length - 5);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('names', 'fields') + ')';
}
}
else if (name.indexOf('Types') === name.length - 5) {
const baseField = name.slice(0, name.length - 5);
if (this.metadata.get(['entityDefs', scope, 'fields', baseField])) {
label = this.language.translate(baseField, 'fields', scope) +
' (' + this.language.translate('types', 'fields') + ')';
}
}
return label;
}
}
export default FieldLanguage;

View File

@@ -0,0 +1,71 @@
/************************************************************************
* 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';
/**
* @since 9.1.1
* @internal For future use.
*/
export default class {
/**
* @type {Metadata}
* @private
*/
@inject(Metadata)
metadata
/**
* Get foreign field params.
*
* @param {string} entityType
* @param {string} field
* @return {Record|null}
*/
get(entityType, field) {
/** @type {Record|null} */
const params = this.metadata.get(`entityDefs.${entityType}.fields.${field}`);
if (!params) {
return null;
}
const foreignField = params.field;
const link = params.link;
const foreignEntityType = this.metadata.get(`entityDefs.${entityType}.links.${link}.entity`);
if (!foreignEntityType) {
return null;
}
return this.metadata.get(`entityDefs.${foreignEntityType}.links.${foreignField}`);
}
}

View File

@@ -0,0 +1,96 @@
/************************************************************************
* 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.
************************************************************************/
/** @module helpers/misc/foreign-field */
export default class {
/**
* @private
* @type {string}
*/
entityType
/**
* @param {module:views/fields/base} view A field view.
*/
constructor(view) {
/**
* @private
* @type {module:views/fields/base}
*/
this.view = view;
const metadata = view.getMetadata();
const model = view.model;
const field = view.params.field;
const link = view.params.link;
const entityType = metadata.get(['entityDefs', model.entityType, 'links', link, 'entity']) ||
model.entityType;
this.entityType = entityType;
const fieldDefs = metadata.get(['entityDefs', entityType, 'fields', field]) || {};
const type = fieldDefs.type;
const ignoreList = [
'default',
'audited',
'readOnly',
'required',
];
/** @private */
this.foreignParams = {};
view.getFieldManager().getParamList(type).forEach(defs => {
const name = defs.name;
if (ignoreList.includes(name)) {
return;
}
this.foreignParams[name] = fieldDefs[name] || null;
});
}
/**
* @return {Object.<string, *>}
*/
getForeignParams() {
return Espo.Utils.cloneDeep(this.foreignParams);
}
/**
* @return {string}
*/
getEntityType() {
return this.entityType;
}
}

View File

@@ -0,0 +1,142 @@
/************************************************************************
* 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.
************************************************************************/
/** @module helpers/misc/stored-text-search */
export default class {
/**
* @param {module:storage} storage
* @param {string} scope
* @param {Number} [maxCount]
*/
constructor(scope, storage, maxCount) {
this.scope = scope;
this.storage = storage;
this.key = 'textSearches';
this.maxCount = maxCount || 100;
/** @type {string[]|null} */
this.list = null;
}
/**
* Match.
*
* @param {string} text
* @param {Number} [limit]
* @return {string[]}
*/
match(text, limit) {
text = text.toLowerCase().trim();
const list = this.get();
const matchedList = [];
for (const item of list) {
if (item.toLowerCase().startsWith(text)) {
matchedList.push(item);
}
if (limit !== undefined && matchedList.length === limit) {
break;
}
}
return matchedList;
}
/**
* Get stored text filters.
*
* @private
* @return {string[]}
*/
get() {
if (this.list === null) {
this.list = this.getFromStorage();
}
return this.list;
}
/**
* @private
* @return {string[]}
*/
getFromStorage() {
/** @var {string[]} */
return this.storage.get(this.key, this.scope) || [];
}
/**
* Store a text filter.
*
* @param {string} text
*/
store(text) {
text = text.trim();
let list = this.getFromStorage();
const index = list.indexOf(text);
if (index !== -1) {
list.splice(index, 1);
}
list.unshift(text);
if (list.length > this.maxCount) {
list = list.slice(0, this.maxCount);
}
this.list = list;
this.storage.set(this.key, this.scope, list);
}
/**
* Remove a text filter.
*
* @param {string} text
*/
remove(text) {
text = text.trim();
const list = this.getFromStorage();
const index = list.indexOf(text);
if (index === -1) {
return;
}
list.splice(index, 1);
this.list = list;
this.storage.set(this.key, this.scope, list);
}
}

View File

@@ -0,0 +1,159 @@
/************************************************************************
* 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 Settings from 'models/settings';
import Preferences from 'models/preferences';
import AclManager from 'acl-manager';
class MailtoHelper {
/**
* @type {Settings}
*/
@inject(Settings)
config
/**
* @type {Preferences}
*/
@inject(Preferences)
preferences
/**
* @type {AclManager}
*/
@inject(AclManager)
acl
/**
* Whether mailto should be used.
*
* @return {boolean}
*/
toUse() {
return this.config.get('emailForceUseExternalClient') ||
this.preferences.get('emailUseExternalClient') ||
!this.acl.checkScope('Email', 'create');
}
/**
* Compose a mailto link.
*
* @param {Record} attributes
* @return {string}
*/
composeLink(attributes) {
let link = 'mailto:';
link += (attributes.to || '').split(';').join(',');
const params = {};
if (attributes.cc) {
params.cc = attributes.cc.split(';').join(',');
}
let bcc = this.config.get('outboundEmailBccAddress');
if (attributes.bcc) {
if (!bcc) {
bcc = '';
} else {
bcc += ';';
}
bcc += attributes.bcc;
}
if (bcc) {
params.bcc = bcc.split(';').join(',');
}
if (attributes.name) {
params.subject = attributes.name;
}
if (attributes.body) {
params.body = /** @type {string} */attributes.body;
if (attributes.isHtml) {
params.body = this.htmlToPlain(params.body);
}
if (params.body.length > 700) {
params.body = params.body.substring(0, 700) + '...';
}
}
if (attributes.inReplyTo) {
params['In-Reply-To'] = attributes.inReplyTo;
}
let part = '';
for (const key in params) {
if (part !== '') {
part += '&';
}
else {
part += '?';
}
part += key + '=' + encodeURIComponent(params[key]);
}
link += part;
return link;
}
/**
* @private
* @param {string} text
* @returns {string}
*/
htmlToPlain(text) {
text = text || '';
let value = text.replace(/<br\s*\/?>/mg, '\n');
value = value.replace(/<\/p\s*\/?>/mg, '\n\n');
const $div = $('<div>').html(value);
$div.find('style').remove();
$div.find('link[ref="stylesheet"]').remove();
value = $div.text();
return value;
}
}
export default MailtoHelper;

View File

@@ -0,0 +1,34 @@
/************************************************************************
* 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.
************************************************************************/
const values: Record<string, string> = {};
/**
* @internal
*/
export {values as CreatedSecrets};

View File

@@ -0,0 +1,76 @@
/************************************************************************
* 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 AppParams from 'app-params';
import Metadata from 'metadata';
/**
* @since 10.0.0
*/
export default class PipelinesHelper {
/**
* @private
* @type {AppParams}
*/
@inject(AppParams)
appParams
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @param {string} entityType
* @return {{
* id: string,
* name: string,
* stages: {
* id: string,
* name: string,
* style: string|null,
* }[],
* color: number|null,
* }[]}
*/
get(entityType) {
return (this.appParams.get('pipelines') ?? {})[entityType] ?? [];
}
/**
* @param {string} entityType
* @return {boolean}
*/
isEnabled(entityType) {
return this.metadata.get(`scopes.${entityType}.pipelines`) === true;
}
}

View File

@@ -0,0 +1,93 @@
/************************************************************************
* 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, register} from 'di';
import Settings from 'models/settings';
import Metadata from 'metadata';
@register()
class ReactionsHelper {
/**
* @type Settings
*/
@inject(Settings)
config
/**
* @type Metadata
*/
@inject(Metadata)
metadata
/**
* @private
* @type {{
* type: string,
* iconClass: string,
* }[]}
*/
list
/**
* @return {{
* type: string,
* iconClass: string,
* }[]}
*/
getDefinitionList() {
if (!this.list) {
this.list = this.metadata.get('app.reactions.list') || [];
}
return this.list;
}
/**
* @return {string[]}
*/
getAvailableReactions() {
return this.config.get('availableReactions') || []
}
/**
* @param {string|null} type
* @return {string|null}
*/
getIconClass(type) {
const item = this.getDefinitionList().find(it => it.type === type);
if (!item) {
return null;
}
return item.iconClass;
}
}
export default ReactionsHelper;

View File

@@ -0,0 +1,142 @@
/************************************************************************
* 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.
************************************************************************/
/** @module helpers/misc/stored-text-search */
export default class {
/**
* @param {module:storage} storage
* @param {string} scope
* @param {Number} [maxCount]
*/
constructor(scope, storage, maxCount) {
this.scope = scope;
this.storage = storage;
this.key = 'textSearches';
this.maxCount = maxCount || 100;
/** @type {string[]|null} */
this.list = null;
}
/**
* Match.
*
* @param {string} text
* @param {Number} [limit]
* @return {string[]}
*/
match(text, limit) {
text = text.toLowerCase().trim();
const list = this.get();
const matchedList = [];
for (const item of list) {
if (item.toLowerCase().startsWith(text)) {
matchedList.push(item);
}
if (limit !== undefined && matchedList.length === limit) {
break;
}
}
return matchedList;
}
/**
* Get stored text filters.
*
* @private
* @return {string[]}
*/
get() {
if (this.list === null) {
this.list = this.getFromStorage();
}
return this.list;
}
/**
* @private
* @return {string[]}
*/
getFromStorage() {
/** @var {string[]} */
return this.storage.get(this.key, this.scope) || [];
}
/**
* Store a text filter.
*
* @param {string} text
*/
store(text) {
text = text.trim();
let list = this.getFromStorage();
const index = list.indexOf(text);
if (index !== -1) {
list.splice(index, 1);
}
list.unshift(text);
if (list.length > this.maxCount) {
list = list.slice(0, this.maxCount);
}
this.list = list;
this.storage.set(this.key, this.scope, list);
}
/**
* Remove a text filter.
*
* @param {string} text
*/
remove(text) {
text = text.trim();
const list = this.getFromStorage();
const index = list.indexOf(text);
if (index === -1) {
return;
}
list.splice(index, 1);
this.list = list;
this.storage.set(this.key, this.scope, list);
}
}

View File

@@ -0,0 +1,999 @@
/************************************************************************
* 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 $ from 'jquery';
import EditTableModalView from 'views/wysiwyg/modals/edit-table';
import EditCellModalView from 'views/wysiwyg/modals/edit-cell';
/**
* @type {{
* edit: import('ace-builds').edit,
* require: import('ace-builds').require,
* }}
*/
let ace;
function init(langSets) {
$.extend($.summernote.options, {
espoImage: {
icon: '<i class="note-icon-picture"/>',
tooltip: langSets.image.image,
},
espoLink: {
icon: '<i class="note-icon-link"/>',
tooltip: langSets.link.link,
},
espoTable: {
icon: '<i class="note-icon-table"/>',
tooltip: langSets.table.table,
},
popover: {
table: [
['custom', ['tableParams', 'cellParams']],
['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
],
},
});
$.extend($.summernote.plugins, {
'cellParams': function (/** Record */context) {
const ui = $.summernote.ui;
const options = context.options;
const view = /** @type {import('view').default} */options.espoView;
if (!view) {
return;
}
context.memo('button.cellParams', () => {
return ui.button({
className: '',
contents: '<i class="far fa-square fa-sm"/>',
tooltip: view.translate('cell', 'wysiwygLabels'),
click: () => {
context.invoke('cellParams.show');
},
}).render();
});
this.show = function () {
const range = context.invoke('editor.getLastRange');
const $td = $(range.ec).closest('td,th');
const td = /** @type {HTMLTableCellElement} */$td[0];
const width = td.style.width;
const height = td.style.height;
const backgroundColor = td.style.backgroundColor;
const verticalAlign = td.style.verticalAlign;
const params = {
width,
height,
backgroundColor,
verticalAlign,
};
//const prevParams = params;
const modalView = new EditCellModalView({
params,
headerText: view.translate('cell', 'wysiwygLabels'),
onApply: params => applyParams(params),
});
view.assignView('dialog', modalView)
.then(() => {
modalView.render();
});
/**
* @param {{
* width: string|null,
* height: string|null,
* backgroundColor: string|null,
* verticalAlign: string|null,
* }} params
*/
const applyParams = params => {
let backgroundColor = td.style.backgroundColor;
if (backgroundColor === 'transparent') {
backgroundColor = null;
}
td.style.backgroundColor = params.backgroundColor;
td.style.width = params.width;
td.style.height = params.height;
td.style.verticalAlign = params.verticalAlign;
};
};
this.destroy = function () {
if (view) {
view.clearView('dialog');
}
};
},
'tableParams': function (/** Record */context) {
const ui = $.summernote.ui;
const options = context.options;
const view = /** @type {import('view').default} */options.espoView;
if (!view) {
// Prevents an issue with a collapsed modal.
// @todo Revise.
return;
}
context.memo('button.tableParams', () => {
return ui.button({
className: '',
contents: '<i class="note-icon-table"/>',
tooltip: langSets.table.table,
click: () => {
context.invoke('tableParams.show');
},
}).render();
});
this.show = function () {
const range = context.invoke('editor.getLastRange');
const $table = $(range.ec).closest('table');
const table = /** @type {HTMLTableElement} */$table[0];
let borderWidth = table.style.borderWidth;
if (borderWidth !== null) { // @todo
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
if (cell.style.borderWidth !== borderWidth) {
borderWidth = null;
break;
}
}
}
let backgroundColor = table.style.backgroundColor;
if (backgroundColor === 'transparent') {
backgroundColor = null;
}
let borderColor = table.style.borderColor;
if (borderColor !== null) { // @todo
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
if (cell.style.borderColor !== borderColor) {
borderColor = null;
break;
}
}
}
let cellPadding = null;
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
if (cellPadding !== null && cell.style.padding !== cellPadding) {
cellPadding = null;
break;
}
cellPadding = cell.style.padding;
}
let align = null;
if (table.style.marginLeft === 'auto' && table.style.marginRight === '0px') {
align = 'right';
} else if (table.style.marginLeft === 'auto' && table.style.marginRight === 'auto') {
align = 'center';
} else if (table.style.marginLeft === '0px' && table.style.marginRight === 'auto') {
align = 'left';
}
const width = table.style.width;
const height = table.style.height;
const params = {
align,
width,
height,
borderWidth,
borderColor,
cellPadding,
backgroundColor,
};
const prevParams = params;
const modalView = new EditTableModalView({
params,
headerText: langSets.table.table,
onApply: params => applyParams(params),
});
view.assignView('dialog', modalView)
.then(() => {
modalView.render();
});
/**
* @param {{
* align: string|null,
* width: string|null,
* height: string|null,
* borderWidth: string|null,
* borderColor: string|null,
* cellPadding: string|null,
* backgroundColor: string|null,
* }} params
*/
const applyParams = params => {
if (params.align === 'left') {
table.style.marginLeft = '0';
table.style.marginRight = 'auto';
} else if (params.align === 'right') {
table.style.marginLeft = 'auto';
table.style.marginRight = '0';
} else if (params.align === 'center') {
table.style.marginLeft = 'auto';
table.style.marginRight = 'auto';
} else if (params.align === null && prevParams.align !== null) {
table.style.marginLeft = null;
table.style.marginRight = null;
}
table.style.backgroundColor = params.backgroundColor;
table.style.width = params.width;
table.style.height = params.height;
if (params.borderWidth !== null || prevParams.borderWidth !== null) {
table.style.borderWidth = params.borderWidth;
table.style.borderStyle = params.borderWidth !== null ? 'solid' : null;
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
cell.style.borderWidth = params.borderWidth;
cell.style.borderStyle = params.borderWidth !== null ? 'solid' : null;
}
}
if (params.borderColor !== null || prevParams.borderColor !== null) {
table.style.borderColor = params.borderColor;
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
cell.style.borderColor = params.borderColor;
}
}
if (params.cellPadding !== null || prevParams.padding !== null) {
for (const /** HTMLTableCellElement */cell of table.querySelectorAll('td, th')) {
cell.style.padding = params.cellPadding;
}
}
};
};
this.destroy = function () {
if (view) {
view.clearView('dialog');
}
};
},
'aceCodeview': function (/** Record */context) {
const ui = $.summernote.ui;
const options = context.options;
const lang = options.langInfo;
/** @type {JQuery} */
const $editor = context.layoutInfo.editor;
/** @type {JQuery} */
const $editable = context.layoutInfo.editable;
const view = /** @type {import('view').default} */options.espoView;
if (!view) {
return;
}
// noinspection SpellCheckingInspection
let $codable;
/** @type {import('ace-builds').Ace.Editor} */
let aceEditor;
let isActivated = false;
let isReplaced = false;
let isBeingActivated = false;
const EMPTY = '<p>&nbsp;</p>';
context.destroyAceCodeview = function () {
if (isActivated) {
deactivate();
}
};
context.code = function (html) {
if (html === undefined) {
let value;
if (isActivated) {
value = prepareValue(aceEditor.getValue()) || EMPTY
$editable.html(value);
}
return isActivated ? value : $editable.html();
}
if (isActivated) {
aceEditor.setValue(html);
}
$editable.html(html);
this.$note.val(html);
context.triggerEvent('change', html, $editable);
};
const id = 'editor-' + Math.random().toString(36).substring(2, 17);
const prepareValue = (input) => {
return input;
};
const prepareHtml = (/** string */input) => {
return prepareValue(input);
};
const toggle = () => {
isActivated ?
deactivate() :
activate();
context.triggerEvent('codeview.toggled');
};
const deactivate = () => {
if (!isActivated) {
return;
}
$codable.addClass('hidden');
let value = prepareValue(aceEditor.getValue()) || EMPTY;
value = context.invoke('codeview.purify', value);
const isChange = $editable.html() !== value;
$editable.html(value);
$editor.removeClass('codeview');
if (isChange) {
context.triggerEvent('change', $editable.html(), $editable);
}
$editable.focus();
context.invoke('toolbar.updateCodeview', false);
context.invoke('airPopover.updateCodeview', false);
if (aceEditor) {
aceEditor.destroy();
aceEditor = null;
}
context.aceEditor = undefined;
isActivated = false;
};
const activate = () => {
if (isBeingActivated) {
return;
}
isBeingActivated = true;
context.invoke('toolbar.updateCodeview', true);
context.invoke('airPopover.updateCodeview', true);
$editor.addClass('codeview');
if (!isReplaced) {
$codable = $('<div>')
.attr('id', id)
.css('minHeight', 40 + 'px');
$(context.layoutInfo.codable).replaceWith($codable);
context.$aceCodable = $codable;
isReplaced = true;
}
$codable.removeClass('hidden');
if ($editor.hasClass('fullscreen')) {
$codable.css('height', $editable.css('height'));
}
requireAce().then(() => {
const html = prepareHtml($editable.html());
aceEditor = ace.edit(id);
aceEditor.setValue(html);
aceEditor.setOptions({
maxLines: !$editor.hasClass('fullscreen') ? 100000: null,
enableLiveAutocompletion: true,
tabSize: 2,
useSoftTabs: true,
});
aceEditor.setOptions({fontFamily: 'var(--font-family-monospace)'});
aceEditor.setFontSize('var(--font-size-small)');
aceEditor.container.style.lineHeight = 'var(--line-height-small)';
aceEditor.renderer.updateFontSize();
if (options.isDark) {
aceEditor.setOptions({theme: 'ace/theme/tomorrow_night'});
}
aceEditor.getSession().setUseWrapMode(true);
aceEditor.setShowPrintMargin(false);
aceEditor.getSession().setUseWorker(false);
aceEditor.commands.removeCommand('find');
aceEditor.setHighlightActiveLine(false);
aceEditor.focus();
try {
aceEditor.gotoLine(0, 0, false);
}
catch (e) {}
aceEditor.on('blur', e => {
context.triggerEvent('blur.codeview', aceEditor.getValue(), e);
});
aceEditor.on('change', () => {
context.triggerEvent('change.codeview', aceEditor.getValue());
});
// noinspection JSValidateTypes
context.aceEditor = aceEditor;
const modeToRequired = options.handlebars ?
'ace/mode/handlebars' :
'ace/mode/html';
if (options.handlebars) {
aceEditor.completers ??= [];
aceEditor.completers.push({
getCompletions(editor, session, pos, prefix, callback) {
const before = session.getLine(pos.row).substring(0, pos.column);
const lastOpen = before.lastIndexOf('<');
const lastClose = before.lastIndexOf('>');
const insideTag = lastOpen > lastClose;
if (!insideTag) {
return callback(null, []);
}
callback(null, [
{
caption: 'x-if',
snippet: 'x-if="\{\{${1}\}\}"',
value: 'x-if',
meta: 'attribute'
},
{
caption: 'iterate',
snippet: 'iterate="\{\{${1}\}\}"',
value: 'iterate',
meta: 'attribute'
},
]);
}
});
}
const Mode = ace.require(modeToRequired).Mode;
aceEditor.session.setMode(new Mode());
isActivated = true;
isBeingActivated = false;
});
};
context.memo('button.aceCodeview', () => {
return ui.button({
className: 'btn-codeview note-codeview-keep',
contents: '<i class="note-icon-code"/>',
tooltip: lang.options.codeview,
click: () => toggle(),
}).render();
});
/**
* @return Promise
*/
const requireAce = function () {
return Espo.loader.requirePromise('lib!ace')
.then(lib => {
ace = /** window.ace */lib;
const list = [
Espo.loader.requirePromise('lib!ace-ext-language_tools'),
];
list.push(
options.handlebars ?
Espo.loader.requirePromise('lib!ace-mode-handlebars') :
Espo.loader.requirePromise('lib!ace-mode-html')
);
if (options.isDark) {
list.push(Espo.loader.requirePromise('lib!ace-theme-tomorrow_night'));
}
return Promise.all(list);
});
}
},
'espoTable': function (context) {
const ui = $.summernote.ui;
const options = context.options;
const self = options.espoView;
const lang = options.langInfo;
if (!self) {
return;
}
context.memo('button.espoTable', () => {
return ui.buttonGroup([
ui.button({
className: 'dropdown-toggle',
contents: ui.dropdownButtonContents(ui.icon(options.icons.table), options),
tooltip: options.espoTable.tooltip,
data: {
toggle: 'dropdown',
},
}),
ui.dropdown({
title: lang.table.table,
className: 'note-table',
items: [
'<div class="note-dimension-picker">',
'<div class="note-dimension-picker-mousecatcher" data-event="insertTable" data-value="1x1"></div>',
'<div class="note-dimension-picker-highlighted"></div>',
'<div class="note-dimension-picker-unhighlighted"></div>',
'</div>',
'<div class="note-dimension-display">1 x 1</div>',
].join(''),
}),
], {
callback: ($node) => {
const $catcher = $node.find('.note-dimension-picker-mousecatcher');
const createTable = (colCount, rowCount, options) => {
const tds = [];
let tdHTML;
for (let idxCol = 0; idxCol < colCount; idxCol++) {
tds.push('<td>&nbsp;</td>');
}
tdHTML = tds.join('\n');
const trs = [];
let trHTML;
for (let idxRow = 0; idxRow < rowCount; idxRow++) {
trs.push('<tr>' + tdHTML + '</tr>');
}
trHTML = trs.join('\n');
const $table = $('<table>\n' + trHTML + '</table>');
/*if (options.tableBorderWidth !== undefined) {
$table.attr('border', options.tableBorderWidth);
}
if (options.tableCellPadding !== undefined) {
$table.attr('cellpadding', options.tableCellPadding);
}*/
$table.css({
width: '100%',
borderCollapse: 'collapse',
//borderSpacing: 0,
});
if (options && options.tableClassName) {
$table.addClass(options.tableClassName);
}
return $table[0];
};
$catcher
.css({
width: options.insertTableMaxSize.col + 'em',
height: options.insertTableMaxSize.row + 'em',
})
.mousedown(() => {
const $note = context.$note;
const dims = $catcher.data('value').split('x');
const range = $note.summernote('editor.getLastRange').deleteContents();
createTable(dims[0], dims[1], options)
range.insertNode(
createTable(dims[0], dims[1], options)
);
})
.on('mousemove', event => {
const PX_PER_EM = 18;
const $picker = $(event.target.parentNode);
const $dimensionDisplay = $picker.next();
const $catcher = $picker.find('.note-dimension-picker-mousecatcher');
const $highlighted = $picker.find('.note-dimension-picker-highlighted');
const $unhighlighted = $picker.find('.note-dimension-picker-unhighlighted');
let posOffset;
if (event.offsetX === undefined) {
const posCatcher = $(event.target).offset();
posOffset = {
x: event.pageX - posCatcher.left,
y: event.pageY - posCatcher.top
};
} else {
posOffset = {
x: event.offsetX,
y: event.offsetY
};
}
const dim = {
c: Math.ceil(posOffset.x / PX_PER_EM) || 1,
r: Math.ceil(posOffset.y / PX_PER_EM) || 1
};
$highlighted.css({
width: dim.c + 'em',
height: dim.r + 'em'
});
$catcher.data('value', dim.c + 'x' + dim.r);
if (dim.c > 3 && dim.c < options.insertTableMaxSize.col) {
$unhighlighted.css({
width: dim.c + 1 + 'em'
});
}
if (dim.r > 3 && dim.r < options.insertTableMaxSize.row) {
$unhighlighted.css({
height: dim.r + 1 + 'em'
});
}
$dimensionDisplay.html(dim.c + ' x ' + dim.r);
});
},
}).render();
});
},
'espoImage': function (context) {
const ui = $.summernote.ui;
const options = context.options;
const self = options.espoView;
const lang = options.langInfo;
if (!self) {
return;
}
context.memo('button.espoImage', () => {
const button = ui.button({
contents: options.espoImage.icon,
tooltip: options.espoImage.tooltip,
click() {
context.invoke('espoImage.show');
},
});
return button.render();
});
this.initialize = function () {};
this.destroy = function () {
if (!self) {
return;
}
self.clearView('insertImageDialog');
};
this.show = function () {
self.createView('insertImageDialog', 'views/wysiwyg/modals/insert-image', {
labels: {
insert: lang.image.insert,
url: lang.image.url,
selectFromFiles: lang.image.selectFromFiles,
},
}, view => {
view.render();
self.listenToOnce(view, 'upload', (target) => {
self.$summernote.summernote('insertImagesOrCallback', target);
});
self.listenToOnce(view, 'insert', (target) => {
self.$summernote.summernote('insertImage', target);
});
self.listenToOnce(view, 'close', () => {
self.clearView('insertImageDialog');
self.fixPopovers();
});
});
};
},
// Not used?
'linkDialog': function (context) {
const options = context.options;
const self = options.espoView;
const lang = options.langInfo;
if (!self) {
return;
}
this.show = function () {
const linkInfo = context.invoke('editor.getLinkInfo');
self.createView('dialogInsertLink', 'views/wysiwyg/modals/insert-link', {
labels: {
insert: lang.link.insert,
openInNewWindow: lang.link.openInNewWindow,
url: lang.link.url,
textToDisplay: lang.link.textToDisplay,
},
linkInfo: linkInfo,
}, view => {
view.render();
self.listenToOnce(view, 'insert', (data) => {
self.$summernote.summernote('createLink', data);
});
self.listenToOnce(view, 'close', () => {
self.clearView('dialogInsertLink');
self.fixPopovers();
});
});
};
},
'espoLink': function (context) {
const ui = $.summernote.ui;
const options = context.options;
const self = options.espoView;
const lang = options.langInfo;
if (!self) {
return;
}
const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
context.memo('button.espoLink', function () {
const button = ui.button({
contents: options.espoLink.icon,
tooltip: options.espoLink.tooltip + ' (' + (isMacLike ? 'CMD+K': 'CTRL+K') +')',
click() {
context.invoke('espoLink.show');
},
});
return button.render();
});
this.initialize = function () {
this.$modalBody = self.$el.closest('.modal-body');
this.isInModal = this.$modalBody.length > 0;
};
this.destroy = function () {
if (!self) {
return;
}
self.clearView('dialogInsertLink');
};
this.show = function () {
const linkInfo = context.invoke('editor.getLinkInfo');
const container = this.isInModal ? this.$modalBody.get(0) : window;
self.createView('dialogInsertLink', 'views/wysiwyg/modals/insert-link', {
labels: {
insert: lang.link.insert,
openInNewWindow: lang.link.openInNewWindow,
url: lang.link.url,
textToDisplay: lang.link.textToDisplay,
},
linkInfo: linkInfo,
}, (view) => {
view.render();
self.listenToOnce(view, 'insert', (data) => {
const scrollY = ('scrollY' in container) ?
container.scrollY :
container.scrollTop;
self.$summernote.summernote('createLink', data);
setTimeout(() => container.scroll(0, scrollY), 20);
});
self.listenToOnce(view, 'close', () => {
self.clearView('dialogInsertLink');
self.fixPopovers();
});
});
};
},
'fullscreen': function (context) {
const options = context.options;
const self = options.espoView;
if (!self) {
return;
}
this.$window = $(window);
this.$scrollbar = $('html, body');
this.initialize = function () {
this.$editor = context.layoutInfo.editor;
this.$toolbar = context.layoutInfo.toolbar;
this.$editable = context.layoutInfo.editable;
this.$modal = self.$el.closest('.modal');
this.isInModal = this.$modal.length > 0;
};
this.resizeTo = function (size) {
this.$editable.css('height', size.h);
if (context.$aceCodable) {
context.$aceCodable.css('height', size.h);
}
};
this.onResize = function () {
this.resizeTo({
h: this.$window.height() - this.$toolbar.outerHeight(),
});
};
this.isFullscreen = function () {
return this.$editor.hasClass('fullscreen');
};
this.destroy = function () {
this.$window.off('resize.summernote' + self.cid);
if (this.isInModal) {
this.$modal.css('overflow-y', '');
}
else {
this.$scrollbar.css('overflow', '');
}
};
let maxLines;
this.toggle = function () {
this.$editor.toggleClass('fullscreen');
const aceEditor = /** @type {import('ace-builds').Ace.Editor} */context.aceEditor;
if (this.isFullscreen()) {
this.$editable.data('orgHeight', this.$editable.css('height'));
this.$editable.data('orgMaxHeight', this.$editable.css('maxHeight'));
this.$editable.css('maxHeight', '');
this.$window
.on('resize.summernote' + self.cid, this.onResize.bind(this))
.trigger('resize');
if (this.isInModal) {
this.$modal.css('overflow-y', 'hidden');
}
else {
this.$scrollbar.css('overflow', 'hidden');
}
// noinspection JSUnusedGlobalSymbols
this._isFullscreen = true;
if (aceEditor) {
maxLines = aceEditor.getOption('maxLines');
aceEditor.setOptions({maxLines: null});
aceEditor.resize();
}
}
else {
this.$window.off('resize.summernote' + self.cid);
this.resizeTo({h: this.$editable.data('orgHeight')});
if (context.$aceCodable) {
context.$aceCodable.css('height', '');
}
this.$editable.css('maxHeight', this.$editable.css('orgMaxHeight'));
this.$editable.css('height', '');
if (this.isInModal) {
this.$modal.css('overflow-y', '');
} else {
this.$scrollbar.css('overflow', '');
}
// noinspection JSUnusedGlobalSymbols
this._isFullscreen = false;
if (aceEditor) {
aceEditor.setOptions({maxLines: 100000});
aceEditor.resize();
}
}
context.invoke('toolbar.updateFullscreen', this.isFullscreen());
};
},
});
}
export {init};

View File

@@ -0,0 +1,84 @@
/************************************************************************
* 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 DateTime from 'date-time';
import {inject} from 'di';
const nowExpression = /return this\.dateTime\.getNow\(([0-9]+)\);/;
const shiftTodayExpression = /return this\.dateTime\.getDateShiftedFromToday\(([0-9]+), '([a-z]+)'\);/;
const shiftNowExpression = /return this\.dateTime\.getDateTimeShiftedFromNow\(([0-9]+), '([a-z]+)', ([0-9]+)\);/;
export default class DefaultValueProvider {
/**
* @type {DateTime}
*/
@inject(DateTime)
dateTime
/**
* Get a value.
*
* @param {string} key
* @return {*}
*/
get(key) {
if (key === "return this.dateTime.getToday();") {
return this.dateTime.getToday();
}
const matchNow = key.match(nowExpression);
if (matchNow) {
const multiplicity = parseInt(matchNow[1]);
return this.dateTime.getNow(multiplicity);
}
const matchTodayShift = key.match(shiftTodayExpression);
if (matchTodayShift) {
const shift = parseInt(matchTodayShift[1]);
const unit = matchTodayShift[2];
return this.dateTime.getDateShiftedFromToday(shift, unit);
}
const matchNowShift = key.match(shiftNowExpression);
if (matchNowShift) {
const shift = parseInt(matchNowShift[1]);
const unit = matchNowShift[2];
const multiplicity = parseInt(matchNowShift[3]);
return this.dateTime.getDateTimeShiftedFromNow(shift, unit, multiplicity);
}
return undefined;
}
}

View File

@@ -0,0 +1,361 @@
/************************************************************************
* 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';
import ViewHelper from 'view-helper';
import Settings from 'models/settings';
import User from 'models/user';
import AclManager from 'acl-manager';
import Preferences from 'models/preferences';
import PipelinesHelper from 'helpers/misc/pipelines';
/**
* Defaults populator.
*/
class DefaultsPopulator {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @private
* @type {ViewHelper}
*/
@inject(ViewHelper)
viewHelper
/**
* @private
* @type {Settings}
*/
@inject(Settings)
config
/**
* @private
* @type {User}
*/
@inject(User)
user
/**
* @private
* @type {Preferences}
*/
@inject(Preferences)
preferences
/**
* @private
* @type {AclManager}
*/
@inject(AclManager)
acl
/**
* Populate default values.
*
* @param {module:model} model A model.
* @return {Promise}
*/
populate(model) {
model.populateDefaults();
const defaultHash = {};
if (!this.user.isPortal()) {
this.prepare(model, defaultHash);
}
if (this.user.isPortal()) {
this.prepareForPortal(model, defaultHash);
}
this.prepareFields(model, defaultHash);
for (const attr in defaultHash) {
if (model.has(attr)) {
delete defaultHash[attr];
}
}
model.set(defaultHash, {silent: true});
const preparatorClass = this.metadata.get(`clientDefs.${model.entityType}.modelDefaultsPreparator`);
if (!preparatorClass) {
return Promise.resolve();
}
return Espo.loader.requirePromise(preparatorClass)
.then(Class => {
/** @type {import('handlers/model/defaults-preparator').default} */
const preparator = new Class(this.viewHelper);
return preparator.prepare(model)
})
.then(attributes => {
model.set(attributes, {silent: true});
});
}
/**
* @param {module:model} model
* @param {Object.<string, *>} defaultHash
* @private
*/
prepare(model, defaultHash) {
const hasAssignedUsers = model.hasField('assignedUsers') &&
model.getLinkParam('assignedUsers', 'entity') === 'User';
if (model.hasField('assignedUser') || hasAssignedUsers) {
let assignedUserField = 'assignedUser';
if (hasAssignedUsers) {
assignedUserField = 'assignedUsers';
}
if (this.toFillAssignedUser(model, assignedUserField)) {
if (hasAssignedUsers) {
defaultHash['assignedUsersIds'] = [this.user.id];
defaultHash['assignedUsersNames'] = {};
defaultHash['assignedUsersNames'][this.user.id] = this.user.get('name');
}
else {
defaultHash['assignedUserId'] = this.user.id;
defaultHash['assignedUserName'] = this.user.get('name');
}
}
}
const defaultTeamId = this.user.get('defaultTeamId');
if (defaultTeamId) {
if (
model.hasField('teams') &&
!model.getFieldParam('teams', 'default') &&
Espo.Utils.lowerCaseFirst(model.getLinkParam('teams', 'relationName') || '') === 'entityTeam'
) {
defaultHash['teamsIds'] = [defaultTeamId];
defaultHash['teamsNames'] = {};
defaultHash['teamsNames'][defaultTeamId] = this.user.get('defaultTeamName');
}
}
const hasCollaborators = model.hasField('collaborators') &&
model.getLinkParam('collaborators', 'entity') === 'User' &&
this.metadata.get(`scopes.${model.entityType}.collaborators`);
if (hasCollaborators) {
defaultHash.collaboratorsIds = [this.user.id];
defaultHash.collaboratorsNames = {[this.user.id]: this.user.attributes.name};
}
if (this.metadata.get(`scopes.${model.entityType}.pipelines`)) {
const firstPipeline = new PipelinesHelper().get(model.entityType)[0] ?? null;
if (firstPipeline) {
defaultHash.pipelineId = firstPipeline.id;
defaultHash.pipelineName = firstPipeline.name;
defaultHash.pipelineStageId = firstPipeline.stages[0]?.id ?? null;
defaultHash.pipelineStageName = firstPipeline.stages[0]?.name ?? null;
}
}
}
/**
*
* @param {import('model').default} model
* @param {string} assignedUserField
*/
toFillAssignedUser(model, assignedUserField) {
if (!this.preferences.get('doNotFillAssignedUserIfNotRequired')) {
return true;
}
if (model.getFieldParam(assignedUserField, 'required')) {
return true;
}
if (this.acl.getPermissionLevel('assignmentPermission') === 'no') {
return true;
}
if (
this.acl.getPermissionLevel('assignmentPermission') === 'team' &&
!this.user.get('defaultTeamId')
) {
return true;
}
if (this.acl.getLevel(model.entityType, 'read') === 'own') {
return true;
}
if (!this.acl.checkField(model.entityType, assignedUserField, 'edit')) {
return true;
}
return false;
}
/**
* @param {module:model} model
* @param {Object.<string, *>} defaultHash
* @private
*/
prepareForPortal(model, defaultHash) {
const accountLink = this.metadata.get(`aclDefs.${model.entityType}.accountLink`);
const contactLink = this.metadata.get(`aclDefs.${model.entityType}.contactLink`);
if (
accountLink &&
model.hasField(accountLink) &&
['belongsTo', 'hasOne'].includes(model.getLinkType(accountLink)) &&
model.getLinkParam(accountLink, 'entity') === 'Account'
) {
if (this.user.attributes.accountId) {
defaultHash[accountLink + 'Id'] = this.user.attributes.accountId;
defaultHash[accountLink + 'Name'] = this.user.attributes.accuntName;
}
}
if (
contactLink &&
model.hasField(contactLink) &&
['belongsTo', 'hasOne'].includes(model.getLinkType(contactLink))&&
model.getLinkParam(contactLink, 'entity') === 'Contact'
) {
if (this.user.attributes.contactId) {
defaultHash[contactLink + 'Id'] = this.user.attributes.contactId;
defaultHash[contactLink + 'Name'] = this.user.attributes.contactName;
}
}
if (
accountLink &&
model.hasField(accountLink) &&
model.getLinkType(accountLink) === 'hasMany' &&
model.getLinkParam(accountLink, 'entity') === 'Account'
) {
if (this.user.attributes.accountsIds) {
defaultHash['accountsIds'] = [...this.user.attributes.accountsIds];
defaultHash['accountsNames'] = {...this.user.attributes.accountsNames};
}
}
if (
contactLink &&
model.hasField(contactLink) &&
model.getLinkType(contactLink) === 'hasMany'&&
model.getLinkParam(contactLink, 'entity') === 'Contact'
) {
if (this.user.attributes.contactId) {
defaultHash['contactsIds'] = [this.user.attributes.contactId];
defaultHash['contactsNames'] = {[this.user.attributes.contactId]: this.user.attributes.contactName};
}
}
if (
model.hasField('parent') &&
model.getLinkType('parent') === 'belongsToParent'
) {
if (!this.config.get('b2cMode')) {
if (
this.user.attributes.accountId &&
(model.getFieldParam('parent', 'entityList') || []).includes('Account')
) {
defaultHash['parentId'] = this.user.attributes.accountId;
defaultHash['parentName'] = this.user.attributes.accountName;
defaultHash['parentType'] = 'Account';
}
} else {
if (
this.user.attributes.contactId &&
(model.getFieldParam('parent', 'entityList') || []).includes('Contact')
) {
defaultHash['parentId'] = this.user.attributes.contactId;
defaultHash['parentName'] = this.user.attributes.contactName;
defaultHash['parentType'] = 'Contact';
}
}
}
}
/**
* @param {module:model} model
* @param {Object.<string, *>} defaultHash
* @private
*/
prepareFields(model, defaultHash) {
const set = (attribute, value) => {
if (
attribute in defaultHash ||
model.has(attribute)
) {
return;
}
defaultHash[attribute] = value;
};
model.getFieldList().forEach(field => {
const type = model.getFieldType(field);
if (!type) {
return;
}
if (
model.getFieldParam(field, 'disabled') ||
model.getFieldParam(field, 'utility')
) {
return;
}
if (type === 'enum') {
/** @type {string[]} */
const options = model.getFieldParam(field, 'options') || [];
let value = options[0] || '';
value = value !== '' ? value : null;
if (value) {
set(field, value);
}
}
});
}
}
export default DefaultsPopulator;

View File

@@ -0,0 +1,364 @@
/************************************************************************
* 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';
import AclManager from 'acl-manager';
import Router from 'router';
import ModalBarProvider from 'helpers/site/modal-bar-provider';
import EditModalView from 'views/modals/edit';
import Language from 'language';
/**
* A record-modal helper. Use to render the quick view and quick edit modals.
*/
class RecordModalHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @private
* @type {AclManager}
*/
@inject(AclManager)
acl
/**
* @private
* @type {Router}
*/
@inject(Router)
router
/**
* @private
* @type {Language}
*/
@inject(Language)
language
/**
* @private
* @type {ModalBarProvider}
*/
@inject(ModalBarProvider)
modalBarProvider
/**
* Show the 'detail' modal.
*
* @param {import('view').default<any>} view
* @param {{
* id: string,
* entityType: string,
* model?: import('model').default | null,
* editDisabled?: boolean,
* removeDisabled?: boolean,
* fullFormDisabled?: boolean,
* rootUrl?: string,
* fullFormUrl?: string,
* layoutName?: string,
* beforeSave?: function(import('model').default, Record),
* afterSave?: function(import('model').default, {bypassClose: boolean} & Record),
* beforeDestroy?: function(import('model').default),
* afterDestroy?: function(import('model').default),
* beforeRender?: function(import('views/modals/detail').default),
* onClose?: function(),
* collapseDisabled?: boolean,
* }} params
* @return {Promise<import('views/modals/detail').default>}
*/
async showDetail(view, params) {
const id = params.id;
// noinspection JSUnresolvedReference
const entityType = params.entityType || params.scope;
const model = params.model;
if (!id || !entityType) {
console.error("Bad data.");
return Promise.reject();
}
if (model && !this.acl.checkScope(model.entityType, 'read')) {
return Promise.reject();
}
const viewName = this.metadata.get(`clientDefs.${entityType}.modalViews.detail`) ||
'views/modals/detail';
Espo.Ui.notifyWait();
/** @type {module:views/modals/detail~options & module:views/modal~Options} */
const options = {
entityType: entityType,
model: model,
id: id,
quickEditDisabled: params.editDisabled,
rootUrl: params.rootUrl,
removeDisabled: params.removeDisabled,
layoutName: params.layoutName,
fullFormDisabled: params.fullFormDisabled,
fullFormUrl: params.fullFormUrl,
collapseDisabled: params.collapseDisabled,
};
Espo.Ui.notifyWait();
const modalView = /** @type {import('views/modals/detail').default} */
await view.createView('modal', viewName, options);
// @todo Revise.
view.listenToOnce(modalView, 'remove', () => view.clearView('modal'));
if (params.beforeSave) {
modalView.listenTo(modalView, 'before:save', (model, o) => {
params.beforeSave(model, o);
});
}
if (params.afterSave) {
modalView.listenTo(modalView, 'after:save', (model, /** Record */o) => {
params.afterSave(model, {...o});
});
}
if (params.beforeDestroy) {
modalView.listenToOnce(modalView, 'before:delete', model => params.beforeDestroy(model));
}
if (params.afterDestroy) {
modalView.listenToOnce(modalView, 'after:delete', model => params.afterDestroy(model));
}
if (params.beforeRender) {
params.beforeRender(modalView);
}
if (params.onClose) {
view.listenToOnce(modalView, 'close', () => params.onClose());
}
await modalView.render();
Espo.Ui.notify();
return modalView;
}
/**
* Show the 'edit' modal.
*
* @param {import('view').default} view
* @param {{
* entityType: string,
* id?: string,
* model?: import('model').default | null,
* rootUrl?: string,
* fullFormDisabled?: boolean,
* fullFormUrl?: string,
* returnUrl?: string,
* layoutName?: string,
* beforeSave?: function(import('model').default, Record),
* afterSave?: function(import('model').default, {bypassClose: boolean} & Record),
* beforeRender?: function(import('views/modals/edit').default),
* onClose?: function(),
* returnDispatchParams?: {
* controller: string,
* action: string|null,
* options: {isReturn?: boolean} & Record,
* },
* collapseDisabled?: boolean,
* }} params
* @return {Promise<import('views/modals/edit').default>}
* @since 9.1.0
*/
async showEdit(view, params) {
const id = params.id;
const entityType = params.entityType;
const model = params.model;
if (this.modalBarProvider.get()) {
const barView = this.modalBarProvider.get();
const foundModalView = barView.getModalViewList().find(view => {
return view instanceof EditModalView &&
view.id === id && view.entityType === entityType;
});
if (foundModalView) {
const message = this.language.translate('sameRecordIsAlreadyBeingEdited', 'messages');
Espo.Ui.warning(message);
throw new Error();
}
}
const viewName = this.metadata.get(`clientDefs.${entityType}.modalViews.edit`) ||
'views/modals/edit';
/** @type {module:views/modals/edit~options & module:views/modal~Options} */
const options = {
entityType: entityType,
id: id,
model: model,
fullFormDisabled: params.fullFormDisabled,
returnUrl: params.returnUrl || this.router.getCurrentUrl(),
returnDispatchParams: params.returnDispatchParams,
layoutName: params.layoutName,
fullFormUrl: params.fullFormUrl,
collapseDisabled: params.collapseDisabled,
};
if (params.rootUrl) {
options.rootUrl = params.rootUrl;
}
Espo.Ui.notifyWait();
const modalView = /** @type {import('views/modals/edit').default} */
await view.createView('modal', viewName, options)
// @todo Revise.
modalView.listenToOnce(modalView, 'remove', () => view.clearView('modal'));
if (params.beforeSave) {
modalView.listenTo(modalView, 'before:save', (model, o) => {
params.beforeSave(model, o);
});
}
if (params.afterSave) {
modalView.listenTo(modalView, 'after:save', (model, /** Record */o) => {
params.afterSave(model, {...o});
});
}
if (params.beforeRender) {
params.beforeRender(modalView);
}
if (params.onClose) {
view.listenToOnce(modalView, 'close', () => params.onClose());
}
await modalView.render();
Espo.Ui.notify();
return modalView;
}
/**
* Show the 'create' modal.
*
* @param {import('view').default<any>} view
* @param {{
* entityType: string,
* rootUrl?: string,
* fullFormDisabled?: boolean,
* fullFormUrl?: string,
* returnUrl?: string,
* relate?: import('model').SetRelateItem | import('model').SetRelateItem[],
* attributes?: Record.<string, *>,
* afterSave?: function(import('model').default, {bypassClose: boolean} & Record),
* beforeRender?: function(import('views/modals/edit').default),
* onClose?: function(),
* focusForCreate?: boolean,
* layoutName?: string,
* returnDispatchParams?: {
* controller: string,
* action: string|null,
* options: {isReturn?: boolean} & Record,
* },
* collapseDisabled?: boolean,
* }} params
* @return {Promise<import('views/modals/edit').default>}
* @since 9.1.0
*/
async showCreate(view, params) {
const entityType = params.entityType;
const viewName = this.metadata.get(`clientDefs.${entityType}.modalViews.edit`) ||
'views/modals/edit';
/** @type {module:views/modals/edit~options & module:views/modal~Options} */
const options = {
entityType: entityType,
fullFormDisabled: params.fullFormDisabled,
returnUrl: params.returnUrl || this.router.getCurrentUrl(),
returnDispatchParams: params.returnDispatchParams,
relate: params.relate,
attributes: params.attributes,
focusForCreate: params.focusForCreate,
layoutName: params.layoutName,
fullFormUrl: params.fullFormUrl,
collapseDisabled: params.collapseDisabled,
};
if (params.rootUrl) {
options.rootUrl = params.rootUrl;
}
Espo.Ui.notifyWait();
const modalView = /** @type {import('views/modals/edit').default} */
await view.createView('modal', viewName, options);
// @todo Revise.
modalView.listenToOnce(modalView, 'remove', () => view.clearView('modal'));
if (params.afterSave) {
modalView.listenTo(modalView, 'after:save', (model, /** Record */o) => {
params.afterSave(model, {...o});
});
}
if (params.beforeRender) {
params.beforeRender(modalView);
}
if (params.onClose) {
view.listenToOnce(modalView, 'close', () => params.onClose());
}
await modalView.render();
Espo.Ui.notify();
return modalView;
}
}
export default RecordModalHelper;

View File

@@ -0,0 +1,109 @@
/************************************************************************
* 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';
import RecordModal from 'helpers/record-modal';
/**
* @internal
*/
class CreateRelatedHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @param {import('view').default} view
*/
constructor(view) {
/** @private */
this.view = view;
}
/**
* @param {import('model').default} model
* @param {string} link
* @param {{
* focusForCreate?: boolean,
* afterSave: function(import('model').default),
* }} [options]
* @return {Promise<import('views/modals/edit').default>}
*/
async process(model, link, options = {}) {
const scope = model.defs['links'][link].entity;
const foreignLink = model.defs['links'][link].foreign;
/** @type {Record} */
const panelDefs = this.metadata.get(`clientDefs.${model.entityType}.relationshipPanels.${link}`) || {};
const attributeMap = panelDefs.createAttributeMap || {};
const handler = panelDefs.createHandler;
let attributes = {};
Object.keys(attributeMap).forEach(attr => attributes[attributeMap[attr]] = model.get(attr));
if (handler) {
const Handler = await Espo.loader.requirePromise(handler);
/** @type {import('contracts/relation').CreateRelatedHandler} */
const handlerObj = new Handler(this.view.getHelper(), {link: link});
const additionalAttributes = await handlerObj.getAttributes(model, link);
attributes = {...attributes, ...additionalAttributes};
}
const helper = new RecordModal();
return await helper.showCreate(this.view, {
entityType: scope,
relate: {
model: model,
link: foreignLink,
},
attributes: attributes,
focusForCreate: options.focusForCreate,
afterSave: m => {
if (options.afterSave) {
options.afterSave(m);
}
model.trigger(`update-related:${link}`);
model.trigger('after:relate');
model.trigger(`after:relate:${link}`);
},
});
}
}
export default CreateRelatedHelper;

View File

@@ -0,0 +1,376 @@
/************************************************************************
* 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 Language from 'language';
import ViewHelper from 'view-helper';
import FieldManager from 'field-manager';
/**
* @internal
*/
class LayoutConverter {
/**
* @type {Language}
* @private
*/
@inject(Language)
language
/**
* @type {ViewHelper}
* @private
*/
@inject(ViewHelper)
viewHelper
/**
* @type {FieldManager}
* @private
*/
@inject(FieldManager)
fieldManager
/**
* Convert a detail layout to an internal layout.
*
* @param {import('views/record/detail').PanelDefs[]} simplifiedLayout A detail layout.
* @param {{
* selector: string,
* id: string,
* entityType: string | null,
* model: import('model').default,
* panelFieldListMap: Record<string, string[]>,
* middlePanelDefs: Record<string, any>,
* middlePanelDefsList: Record<string, any>[],
* underShowMoreDetailPanelList: string[],
* dynamicLogic: import('dynamic-logic').default | null,
* dynamicLogicDefs: import('dynamic-logic').Defs | null,
* recordHelper: import('view-record-helper').default,
* hidePanel: (name: string) => void,
* validateField: (name: string) => boolean,
* readOnly: boolean,
* readOnlyLocked: boolean,
* inlineEditDisabled: boolean,
* dataObject: Record<string, any>,
* fieldsMode: 'detail' | 'edit' | 'list',
* }} options
* @return {Record<string, any>[]}
*/
convert(simplifiedLayout, options) {
const layout = [];
const el = options.selector || '#' + options.id;
const panelFieldListMap = options.panelFieldListMap;
const middlePanelDefs = options.middlePanelDefs;
const middlePanelDefsList = options.middlePanelDefsList;
const underShowMoreDetailPanelList = options.underShowMoreDetailPanelList;
const recordHelper = options.recordHelper;
const dynamicLogic = options.dynamicLogic;
const dynamicLogicDefs = options.dynamicLogicDefs;
const entityType = options.entityType;
let tabNumber = -1;
for (let p = 0; p < simplifiedLayout.length; p++) {
const item = simplifiedLayout[p];
const panel = {};
const tabBreak = item.tabBreak || p === 0;
if (tabBreak) {
tabNumber++;
}
if ('customLabel' in item) {
panel.label = item.customLabel;
if (panel.label) {
panel.label = this.language.translate(panel.label, 'panelCustomLabels', entityType);
}
} else {
panel.label = item.label || null;
if (panel.label) {
panel.label = panel.label[0] === '$' ?
this.language.translate(panel.label.substring(1), 'panels', entityType) :
this.language.translate(panel.label, 'labels', entityType);
}
}
panel.name = item.name || 'panel-' + p.toString();
panel.style = item.style || 'default';
panel.rows = [];
panel.tabNumber = tabNumber;
panel.noteText = item.noteText;
panel.noteStyle = item.noteStyle || 'info';
if (panel.noteText) {
if (panel.noteText.startsWith('$') && !panel.noteText.includes(' ')) {
const label = panel.noteText.substring(1);
panel.noteText = this.language.translate(label, 'panelNotes', entityType);
}
panel.noteText = this.viewHelper.transformMarkdownText(panel.noteText);
}
middlePanelDefs[panel.name] = {
name: panel.name,
style: panel.style,
tabNumber: panel.tabNumber,
tabBreak: tabBreak,
tabLabel: item.tabLabel,
};
middlePanelDefsList.push(middlePanelDefs[panel.name]);
// noinspection JSUnresolvedReference
if (item.dynamicLogicVisible && dynamicLogic) {
dynamicLogic.addPanelVisibleCondition(panel.name, item.dynamicLogicVisible);
}
// noinspection JSUnresolvedReference
if (item.dynamicLogicStyled && dynamicLogic) {
dynamicLogic.addPanelStyledCondition(panel.name, item.dynamicLogicStyled);
}
// noinspection JSUnresolvedReference
if (item.hidden && tabNumber === 0) {
panel.hidden = true;
options.hidePanel(panel.name);
underShowMoreDetailPanelList.push(panel.name);
}
let lType = 'rows';
if (item.columns) {
lType = 'columns';
panel.columns = [];
}
if (panel.name) {
panelFieldListMap[panel.name] = [];
}
for (const [i, itemI] of item[lType].entries()) {
const row = [];
for (const cellDefs of itemI) {
if (cellDefs === false) {
row.push(false);
continue;
}
let view = cellDefs.view;
let name = cellDefs.name;
if (!name && view && typeof view === 'object') {
name = view.name;
}
if (!name) {
console.warn(`No 'name' specified in detail layout cell.`);
continue;
}
let selector;
if (view && typeof view === 'object') {
view.model = options.model;
view.mode = options.fieldsMode;
if (options.readOnly) {
view.setReadOnly();
}
selector = `.field[data-name="${name}"]`;
}
if (panel.name) {
panelFieldListMap[panel.name].push(name);
}
const type = cellDefs.type || options.model.getFieldType(name) || 'base';
view = view ||
options.model.getFieldParam(name, 'view') ||
this.fieldManager.getViewName(type);
const o = {
fullSelector: `${el} .middle .field[data-name="${name}"]`,
defs: {
name: name,
params: cellDefs.params || {},
},
mode: options.fieldsMode,
};
if (options.readOnly) {
o.readOnly = true;
}
if (cellDefs.readOnly) {
o.readOnly = true;
o.readOnlyLocked = true;
}
if (options.readOnlyLocked) {
o.readOnlyLocked = true;
}
if (options.inlineEditDisabled || cellDefs.inlineEditDisabled) {
o.inlineEditDisabled = true;
}
// noinspection JSUnresolvedReference
let fullWidth = cellDefs.fullWidth || false;
if (!fullWidth) {
if (item[lType][i].length === 1) {
fullWidth = true;
}
}
if (recordHelper.getFieldStateParam(name, 'hidden')) {
o.disabled = true;
}
if (recordHelper.getFieldStateParam(name, 'hiddenLocked')) {
o.disabledLocked = true;
}
if (recordHelper.getFieldStateParam(name, 'readOnly')) {
o.readOnly = true;
}
if (!o.readOnlyLocked && recordHelper.getFieldStateParam(name, 'readOnlyLocked')) {
o.readOnlyLocked = true;
}
if (recordHelper.getFieldStateParam(name, 'required') !== null) {
o.defs.params = o.defs.params || {};
o.defs.params.required = recordHelper.getFieldStateParam(name, 'required');
}
if (recordHelper.hasFieldOptionList(name)) {
o.customOptionList = recordHelper.getFieldOptionList(name);
}
o.validateCallback = () => options.validateField(name);
o.recordHelper = recordHelper;
o.dataObject = options.dataObject;
if (cellDefs.options) {
for (const optionName in cellDefs.options) {
if (typeof o[optionName] !== 'undefined') {
continue;
}
o[optionName] = cellDefs.options[optionName];
}
}
if (dynamicLogicDefs?.cascadingFields?.[name]) {
o.cascadingLogic = dynamicLogicDefs?.cascadingFields?.[name];
}
const cell = {
name: `${name}Field`,
view: view,
field: name,
fullSelector: `${el} .middle .field[data-name="${name}"]`,
fullWidth: fullWidth,
options: o,
};
if (selector) {
cell.selector = selector;
}
if ('labelText' in cellDefs) {
o.labelText = cellDefs.labelText;
cell.customLabel = cellDefs.labelText;
} else if (cellDefs.labelTranslation) {
o.labelText = this.language.translatePath(cellDefs.labelTranslation);
cell.customLabel = o.labelText;
}
if ('customLabel' in cellDefs) {
cell.customLabel = cellDefs.customLabel;
}
if ('label' in cellDefs) {
cell.label = cellDefs.label;
}
if (
view &&
typeof view === 'object' &&
!cell.customLabel &&
!cell.label &&
view.getLabelText()
) {
cell.customLabel = view.getLabelText();
}
if ('customCode' in cellDefs) {
cell.customCode = cellDefs.customCode;
}
if ('noLabel' in cellDefs) {
cell.noLabel = cellDefs.noLabel;
}
if ('span' in cellDefs) {
cell.span = cellDefs.span;
}
row.push(cell);
}
panel[lType].push(row);
}
layout.push(panel);
}
return layout;
}
}
export default LayoutConverter;

View File

@@ -0,0 +1,237 @@
/************************************************************************
* 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.
************************************************************************/
/**
* @internal
*/
export default class ListColumnResizeHelper {
/**
* @type {{
* startX: number,
* startWidth: number,
* thElement: HTMLTableCellElement,
* name: string,
* inPx: boolean,
* onRight: boolean,
* newWidth: number|null,
* thElements: HTMLTableCellElement[],
* }}
* @private
*/
item
/**
* A min width in pixels.
*
* @private
* @type {number}
*/
minWidth = 30
static selector = 'table > thead > tr > th > .column-resizer';
/**
* @param {import('views/record/list-base').default} view
* @param {import('helpers/list/settings').default} helper
*/
constructor(view, helper) {
/** @private */
this.view = view;
/** @private */
this.helper = helper;
/**
* @private
* @type {number}
*/
this.fontSizeFactor = view.getThemeManager().getFontSizeFactor();
this.onPointerUpBind = this.onPointerUp.bind(this);
this.onPointerMoveBind = this.onPointerMove.bind(this);
view.addHandler('pointerdown', ListColumnResizeHelper.selector, (/** PointerEvent */e, target) => {
this.onPointerDown(e, target);
});
}
/**
* @private
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
onPointerDown(event, target) {
if (!event.isPrimary) {
return;
}
this.startResizeInit(event, target)
window.addEventListener('pointerup', this.onPointerUpBind);
window.addEventListener('pointermove', this.onPointerMoveBind);
}
/**
* @private
* @param {PointerEvent} event
* @param {HTMLElement} target
*/
startResizeInit(event, target) {
const th = /** @type {HTMLTableCellElement} */target.parentNode;
const thElements = [...th.parentNode.querySelectorAll(':scope > th.field-header-cell')]
.filter(it => !it.style.width);
this.item = {
startX: event.clientX,
startWidth: th.clientWidth,
thElement: th,
name: th.dataset.name,
inPx: th.style.width && th.style.width.endsWith('px'),
onRight: target.classList.contains('column-resizer-right'),
newWidth: null,
thElements: thElements,
};
document.body.style.cursor = 'col-resize';
const trElement = this.item.thElement.closest('tr');
trElement.classList.add('being-column-resized');
this.item.thElement.classList.add('being-resized');
}
/**
* @private
* @param {number} width
*/
isWidthOk(width) {
if (width < this.minWidth * this.fontSizeFactor) {
return false;
}
for (const th of this.item.thElements) {
if (th.style.width) {
continue;
}
if (th.clientWidth < this.minWidth * this.fontSizeFactor) {
return false;
}
}
return true;
}
/**
* @private
* @param {PointerEvent} event
*/
onPointerMove(event) {
let diff = event.clientX - this.item.startX;
if (!this.item.onRight) {
diff *= -1;
}
const width = this.item.startWidth + diff;
if (!this.isWidthOk(width)) {
return;
}
const previousWidth = this.item.newWidth;
const previousStyleWidth = this.item.thElement.style.width;
this.item.newWidth = width;
this.item.thElement.style.width = width.toString() + 'px';
if (!this.isWidthOk(width)) {
if (previousWidth) {
this.item.newWidth = previousWidth;
}
this.item.thElement.style.width = previousStyleWidth;
}
}
/**
* @private
*/
onPointerUp() {
window.removeEventListener('pointermove', this.onPointerMoveBind);
window.removeEventListener('pointerup', this.onPointerUpBind);
document.body.style.cursor = '';
const width = this.item.newWidth;
if (width === null) {
this.disableResizingState();
return;
}
let unit = 'px';
let value = width;
if (!this.item.inPx) {
const tableElement = this.item.thElement.closest('table');
const tableWidth = tableElement.clientWidth;
const factor = Math.pow(10, 4);
const widthPercents = width / tableWidth;
const widthPercentsRounded = Math.floor(factor * widthPercents * 100) / factor;
this.item.thElement.style.width = widthPercentsRounded.toString() + '%';
unit = '%';
value = widthPercentsRounded;
}
if (this.item.inPx) {
value = value / this.fontSizeFactor;
}
this.helper.storeColumnWidth(this.item.name, {value: value, unit: unit});
this.disableResizingState();
}
/**
* @private
*/
disableResizingState() {
const trElement = this.item.thElement.closest('tr')
trElement.classList.remove('being-column-resized');
this.item.thElement.classList.remove('being-resized');
this.item = undefined;
}
}

View File

@@ -0,0 +1,214 @@
/************************************************************************
* 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.
************************************************************************/
export default class ListColumnWidthControlHelper {
/**
* A min width in pixels.
*
* @private
* @type {number}
*/
minWidth = 30
/**
* @param {{
* view?: import('views/record/list-base').default,
* helper: import('helpers/list/settings').default,
* layoutProvider?: function(): {
* name: string,
* width?: number,
* widthPx?: number,
* hidden?: boolean,
* }[]
* }} options
*/
constructor(options) {
/** @private */
this.view = options.view;
/** @private */
this.helper = options.helper;
/** @private */
this.layoutProvider = options.layoutProvider;
}
/**
* Adjust widths.
*
* @param {{
* tableWidth?: number,
* staticWidth?: number,
* }} [options]
* @return {boolean}
*/
adjust(options = {}) {
let tableWidthData;
if (options.tableWidth === undefined || options.staticWidth === undefined) {
tableWidthData = this.getTableWidths();
}
const tableWidth = options.tableWidth === undefined ? tableWidthData.table : options.tableWidth;
const staticWidth = options.staticWidth === undefined ? tableWidthData.static : options.staticWidth;
const widthMap = this.helper.getColumnWidthMap();
/**
* @type {{
* name: string,
* width: {
* value: number,
* unit: 'px'|'%',
* }|null,
* isCustom: boolean,
* widthPx: number|null,
* }[]}
*/
const list = this.layoutProvider()
.filter(it => !this.helper.isColumnHidden(it.name, it.hidden))
.map(it => {
let widthItem = widthMap[it.name];
const isCustom = !!widthItem;
if (!widthItem) {
widthItem = null;
if (it.width) {
widthItem = {value: it.width, unit: '%'};
} else if (it.widthPx) {
widthItem = {value: it.widthPx, unit: 'px'};
}
}
let widthPx = null;
if (widthItem) {
if (widthItem.unit === 'px') {
widthPx = widthItem.value;
} else {
widthPx = tableWidth * (widthItem.value / 100.0);
}
}
return {
name: it.name,
width: widthItem,
isCustom: isCustom,
widthPx: widthPx,
};
});
const flexColumnCount = list.filter(it => !it.width).length;
const extraWidth = flexColumnCount * this.minWidth;
let sumWidth = 0;
list.filter(it => it.widthPx)
.forEach(it => sumWidth += it.widthPx);
if (tableWidth - extraWidth - staticWidth - sumWidth >= 0) {
return true;
}
const listSorted = list
.filter(it => it.widthPx && it.width)
.sort((a, b) => b.widthPx - a.widthPx);
if (!listSorted.length) {
return true;
}
const item = listSorted[0];
const reduceWidthPx = 10;
if (item.widthPx < reduceWidthPx) {
return true;
}
/** @type {{value: number, unit: 'px'|'%'}} */
let newWidth;
if (item.width.unit === 'px') {
newWidth = {
value: item.width.value - reduceWidthPx,
unit: 'px',
};
} else {
const factor = Math.pow(10, 4);
const reducePercent = Math.floor(factor * (reduceWidthPx / tableWidth) * 100) / factor;
newWidth = {
value: item.width.value - reducePercent,
unit: '%',
}
}
const map = this.helper.getColumnWidthMap();
map[item.name] = newWidth;
this.helper.storeColumnWidthMap(map);
this.adjust({tableWidth, staticWidth});
return false;
}
/**
* @private
* @return {{
* table: number,
* static: number,
* }|null}
*/
getTableWidths() {
const tableElement = this.view.element.querySelector('.list > table');
if (!tableElement) {
return null;
}
const tableWidth = tableElement.clientWidth;
let staticWidth = 0;
tableElement.querySelectorAll(':scope > thead > tr > th').forEach(th => {
if (
!th.classList.contains('field-header-cell') ||
th.classList.contains('action-cell')
) {
staticWidth += th.clientWidth;
}
});
return {
table: tableWidth,
static: staticWidth,
};
}
}

View File

@@ -0,0 +1,149 @@
/************************************************************************
* 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 $ from 'jquery';
/**
* @internal
*/
class StickyBarHelper {
/**
* @param {import('views/record/detail').default} view
* @param {string} stickButtonsFormBottomSelector
* @param {boolean} stickButtonsContainerAllTheWay
* @param {string} numId
*/
constructor(view, stickButtonsFormBottomSelector, stickButtonsContainerAllTheWay, numId) {
this.view = view;
this.stickButtonsFormBottomSelector = stickButtonsFormBottomSelector;
this.stickButtonsContainerAllTheWay = stickButtonsContainerAllTheWay;
this.numId = numId;
this.themeManager = view.getThemeManager();
this.$el = view.$el;
}
init() {
const $containers = this.$el.find('.detail-button-container');
const $container = this.$el.find('.detail-button-container.record-buttons');
if (!$container.length) {
return;
}
const navbarHeight = this.themeManager.getParam('navbarHeight') * this.themeManager.getFontSizeFactor();
const screenWidthXs = this.themeManager.getParam('screenWidthXs');
const isSmallScreen = $(window.document).width() < screenWidthXs;
const getOffsetTop = (/** JQuery */$element) => {
let element = /** @type {HTMLElement} */$element.get(0);
let value = 0;
while (element) {
value += !isNaN(element.offsetTop) ? element.offsetTop : 0;
element = element.offsetParent;
}
if (isSmallScreen) {
return value;
}
return value - navbarHeight;
};
let stickTop = getOffsetTop($container);
const blockHeight = $container.outerHeight();
stickTop -= 5; // padding;
const $block = $('<div>')
.css('height', blockHeight + 'px')
.html('&nbsp;')
.hide()
.insertAfter($container);
let $middle = this.view.getMiddleView().$el;
const $window = $(window);
const $navbarRight = $('#navbar .navbar-right');
if (this.stickButtonsFormBottomSelector) {
const $bottom = this.$el.find(this.stickButtonsFormBottomSelector);
if ($bottom.length) {
$middle = $bottom;
}
}
$window.off('scroll.detail-' + this.numId);
$window.on('scroll.detail-' + this.numId, () => {
const edge = $middle.position().top + $middle.outerHeight(false) - blockHeight;
const scrollTop = $window.scrollTop();
if (scrollTop >= edge && !this.stickButtonsContainerAllTheWay) {
$containers.hide();
$navbarRight.removeClass('has-sticked-bar');
$block.show();
return;
}
if (isSmallScreen && $('#navbar .navbar-body').hasClass('in')) {
return;
}
if (scrollTop > stickTop) {
if (!$containers.hasClass('stick-sub')) {
$containers.addClass('stick-sub');
$block.show();
}
$navbarRight.addClass('has-sticked-bar');
$containers.show();
return;
}
if ($containers.hasClass('stick-sub')) {
$containers.removeClass('stick-sub');
$navbarRight.removeClass('has-sticked-bar');
$block.hide();
}
$containers.show();
});
}
}
export default StickyBarHelper;

View File

@@ -0,0 +1,229 @@
/************************************************************************
* 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';
/**
* @internal
*/
class SelectRelatedHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @param {import('view').default} view
*/
constructor(view) {
/** @private */
this.view = view;
}
/**
* @param {import('model').default} model
* @param {string} link
* @param {{
* foreignEntityType?: string,
* massSelect?: boolean,
* primaryFilterName?: string,
* boolFilterList?: string[]|string,
* viewKey?: string,
* hasCreate?: boolean,
* onCreate?: function(): void,
* }} options
*/
process(model, link, options = {}) {
if (!options.foreignEntityType && !model.defs['links'][link]) {
throw new Error(`Link ${link} does not exist.`);
}
const scope = options.foreignEntityType || model.defs['links'][link].entity;
/** @var {Object.<string, *>} */
const panelDefs = this.metadata.get(['clientDefs', model.entityType, 'relationshipPanels', link]) || {};
const massRelateEnabled = options.massSelect || panelDefs.massSelect;
let advanced = {};
const foreignLink = model.getLinkParam(link, 'foreign');
if (foreignLink && scope) {
// Select only records not related with any.
const foreignLinkType = this.metadata.get(['entityDefs', scope, 'links', foreignLink, 'type']);
const foreignLinkFieldType = this.metadata.get(['entityDefs', scope, 'fields', foreignLink, 'type']);
if (
['belongsTo', 'belongsToParent'].includes(foreignLinkType) &&
foreignLinkFieldType &&
!advanced[foreignLink] &&
['link', 'linkParent'].includes(foreignLinkFieldType)
) {
advanced[foreignLink] = {
type: 'isNull',
attribute: foreignLink + 'Id',
data: {
type: 'isEmpty',
},
};
}
}
let primaryFilterName = options.primaryFilterName || null;
if (typeof primaryFilterName === 'function') {
primaryFilterName = primaryFilterName.call(this);
}
let dataBoolFilterList = options.boolFilterList;
if (typeof options.boolFilterList === 'string') {
dataBoolFilterList = options.boolFilterList.split(',');
}
let boolFilterList = dataBoolFilterList || panelDefs.selectBoolFilterList;
if (typeof boolFilterList === 'function') {
boolFilterList = boolFilterList.call(this);
}
boolFilterList = Espo.Utils.clone(boolFilterList);
primaryFilterName = primaryFilterName || panelDefs.selectPrimaryFilterName || null;
const viewKey = options.viewKey || 'select';
const viewName = panelDefs.selectModalView ||
this.metadata.get(['clientDefs', scope, 'modalViews', viewKey]) ||
'views/modals/select-records';
Espo.Ui.notifyWait();
const handler = panelDefs.selectHandler || null;
new Promise(resolve => {
if (!handler) {
resolve({});
return;
}
Espo.loader.requirePromise(handler)
.then(Handler => new Handler(this.view.getHelper()))
.then(/** import('contracts/relation').SelectRelatedHandler */handler => {
handler.getFilters(model)
.then(filters => resolve(filters));
});
}).then(filters => {
advanced = {...advanced, ...(filters.advanced || {})};
if (boolFilterList || filters.bool) {
boolFilterList = [
...(boolFilterList || []),
...(filters.bool || []),
];
}
if (filters.primary && !primaryFilterName) {
primaryFilterName = filters.primary;
}
const orderBy = filters.orderBy || panelDefs.selectOrderBy;
const orderDirection = filters.orderBy ? filters.order : panelDefs.selectOrderDirection;
const createButton = options.hasCreate === true && options.onCreate !== undefined;
/** @type {import('views/modals/select-records').default} */
let modalView;
this.view.createView('dialogSelectRelated', viewName, {
scope: scope,
multiple: true,
filters: advanced,
massRelateEnabled: massRelateEnabled,
primaryFilterName: primaryFilterName,
boolFilterList: boolFilterList,
mandatorySelectAttributeList: panelDefs.selectMandatoryAttributeList,
layoutName: panelDefs.selectLayout,
orderBy: orderBy,
orderDirection: orderDirection,
createButton: createButton,
onCreate: () => {
modalView.close();
if (options.onCreate) {
options.onCreate();
}
},
}, view => {
modalView = view;
view.render();
Espo.Ui.notify(false);
this.view.listenToOnce(view, 'select', (selectObj) => {
const data = {};
if (Object.prototype.toString.call(selectObj) === '[object Array]') {
const ids = [];
selectObj.forEach(model => ids.push(model.id));
data.ids = ids;
} else if (selectObj.massRelate) {
data.massRelate = true;
data.where = selectObj.where;
data.searchParams = selectObj.searchParams;
} else {
data.id = selectObj.id;
}
const url = `${model.entityType}/${model.id}/${link}`;
Espo.Ajax.postRequest(url, data)
.then(() => {
Espo.Ui.success(this.view.translate('Linked'))
model.trigger(`update-related:${link}`);
model.trigger('after:relate');
model.trigger(`after:relate:${link}`);
});
});
});
});
}
}
export default SelectRelatedHelper;

View File

@@ -0,0 +1,94 @@
/************************************************************************
* 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';
import Language from 'language';
/**
* A regular expression pattern helper.
*/
class RegExpPatternHelper {
/**
* @private
* @type {Metadata}
*/
@inject(Metadata)
metadata
/**
* @private
* @type {Language}
*/
@inject(Language)
language
/**
*
* @param {string} pattern
* @param {string|null} value
* @param {string} [field]
* @param {string | null} [entityType]
* @return {{message: string}|null}
*/
validate(pattern, value, field, entityType) {
if (value === '' || value === null) {
return null;
}
let messageKey = 'fieldNotMatchingPattern';
if (pattern[0] === '$') {
const patternName = pattern.slice(1);
const foundPattern = this.metadata.get(['app', 'regExpPatterns', patternName, 'pattern']);
if (foundPattern) {
messageKey += '$' + patternName;
pattern = foundPattern;
}
}
const regExp = new RegExp('^' + pattern + '$');
if (regExp.test(value)) {
return null;
}
let message = this.language.translate(messageKey, 'messages')
.replace('{pattern}', pattern);
if (field && entityType) {
message = message.replace('{field}', this.language.translate(field, 'fields', entityType));
}
return {message: message};
}
}
export default RegExpPatternHelper;

View File

@@ -0,0 +1,58 @@
/************************************************************************
* 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 {register} from 'di';
/**
* @internal
*/
@register()
export default class ModalBarProvider {
/**
* @private
* @type {import('views/collapsed-modal-bar').default|null}
*/
view = null
/**
* @internal
* @return {import('views/collapsed-modal-bar').default|null}
*/
get() {
return this.view;
}
/**
* @internal
* @param {import('views/collapsed-modal-bar').default|null} view
*/
set(view) {
this.view = view;
}
}

View File

@@ -0,0 +1,151 @@
/************************************************************************
* 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 {register} from 'di';
import Ui from 'ui';
/** @typedef {import('view').default} View */
/** @typedef {string|function(KeyboardEvent): void} Key */
@register()
export default class ShortcutManager {
/**
* @private
* @type {number}
*/
level = 0
/**
* @private
* @type {{
* view: View[],
* keys: Record.<string, Key>,
* level: number,
* }[]}
*/
items
constructor() {
this.items = [];
document.addEventListener('keydown', event => this.handle(event), {capture: true});
}
/**
* Add a view and keys.
*
* @param {import('view').default<any>} view
* @param {Record.<string, Key>} keys
* @param {{stack: boolean}} [options]
*/
add(view, keys, options = {}) {
if (this.items.find(it => it.view === view)) {
return;
}
if (options.stack) {
this.level ++;
}
this.items.push({
view: view,
keys: keys,
level: this.level,
});
}
/**
* Remove a view.
*
* @param {import('view').default<any>} view
*/
remove(view) {
const index = this.items.findIndex(it => it.view === view);
if (index < 0) {
return;
}
this.items.splice(index, 1);
let maxLevel = 0;
for (const item of this.items) {
if (item.level > maxLevel) {
maxLevel = item.level;
}
}
this.level = maxLevel;
}
/**
* Handle.
*
* @param {KeyboardEvent} event
*/
handle(event) {
const items = this.items.filter(it => it.level === this.level);
if (items.length === 0) {
return;
}
if (Ui.getConfirmCount()) {
return;
}
const key = Espo.Utils.getKeyFromKeyEvent(event);
for (const item of items) {
const subject = item.keys[key];
if (!subject) {
continue;
}
if (typeof subject === 'function') {
subject.call(item.view, event);
break;
}
event.preventDefault();
event.stopPropagation();
const methodName = 'action' + Espo.Utils.upperCaseFirst(subject);
if (typeof item.view[methodName] === 'function') {
item.view[methodName]();
break;
}
}
}
}

View File

@@ -0,0 +1,216 @@
/************************************************************************
* 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.
************************************************************************/
export default class TabsHelper {
/**
* @param {import('models/settings').default} config
* @param {import('models/preferences').default} preferences
* @param {import('models/user').default} user
* @param {import('acl-manager').default} acl
* @param {import('metadata').default} metadata
* @param {import('language').default} language
*/
constructor(config, preferences, user, acl, metadata, language) {
/** @private */
this.config = config;
/** @private */
this.preferences = preferences;
/** @private */
this.user = user;
/** @private */
this.acl = acl;
/** @private */
this.metadata = metadata;
/** @private */
this.language = language;
}
/**
* @typedef {Object} TabsHelper~item
* @property {string} [url]
* @property {string} [text]
* @property {'url'|'divider'} [type]
* @property {(TabsHelper~item|string)[]} [itemList]
*/
/**
* Get the tab list.
*
* @return {(TabsHelper~item|string)[]}
*/
getTabList() {
let tabList = this.preferences.get('useCustomTabList') && !this.preferences.get('addCustomTabs') ?
this.preferences.get('tabList') :
this.config.get('tabList');
if (this.preferences.get('useCustomTabList') && this.preferences.get('addCustomTabs')) {
tabList = [
...tabList,
...(this.preferences.get('tabList') || []),
];
}
return Espo.Utils.cloneDeep(tabList) || [];
}
/**
* Is a tab a divider.
*
* @param {string|{type?: string}} item
*/
isTabDivider(item) {
return typeof item === 'object' && item.type === 'divider';
}
/**
* Is a tab more-delimiter.
*
* @param {string|{type?: string}} item
*/
isTabMoreDelimiter(item) {
return item === '_delimiter_' || item === '_delimiter-ext_';
}
/**
* Is a tab a URL.
*
* @param {string|{type?: string}} item
*/
isTabUrl(item) {
return typeof item === 'object' && item.type === 'url';
}
/**
* Is a tab a group.
*
* @param {string|{type?: string}} item
*/
isTabGroup(item) {
if (!this.isTabDivider(item) && !this.isTabUrl(item) && typeof item === 'object') {
return true;
}
return false;
}
/**
* Is a tab a scope.
*
* @param {string|{type?: string}} item
*/
isTabScope(item) {
if (typeof item === 'object' || this.isTabMoreDelimiter(item) || item === 'Home') {
return false;
}
return true;
}
/**
* Get a translated tab label.
*
* @param {{text?: string}|string} item
*/
getTranslatedTabLabel(item) {
const translateLabel = label => {
if (label.indexOf('$') === 0) {
return this.language.translate(label.slice(1), 'navbarTabs');
}
return label;
};
if (this.isTabDivider(item) || this.isTabUrl(item) || this.isTabUrl(item) || this.isTabGroup(item)) {
if (item.text) {
return translateLabel(item.text);
}
return ''
}
if (item === 'Home') {
return this.language.translate('Home');
}
if (typeof item === 'object') {
return '';
}
return this.language.translate(item, 'scopeNamesPlural');
}
/**
* Check tab access.
*
* @param {Record|string} item
* @return {boolean}
*/
checkTabAccess(item) {
if (this.isTabUrl(item)) {
if (item.onlyAdmin && !this.user.isAdmin()) {
return false;
}
if (!item.aclScope) {
return true;
}
return this.acl.check(item.aclScope);
}
if (item === 'Home' || this.isTabMoreDelimiter(item)) {
return true;
}
/** @type {Record<string, {disabled?: boolean, acl?: boolean, tabAclPermission?: string}>} */
const scopes = this.metadata.get('scopes') || {};
if (!scopes[item]) {
return false;
}
const defs = scopes[item] || {};
if (defs.disabled) {
return false;
}
if (defs.acl) {
return this.acl.check(item);
}
if (defs.tabAclPermission) {
const level = this.acl.getPermissionLevel(defs.tabAclPermission);
return level && level !== 'no';
}
return true;
}
}

View File

@@ -0,0 +1,131 @@
/************************************************************************
* 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 ThemeManager from 'theme-manager';
export default class WindowPanelHelper {
/**
* @type {import('view').default}
* @private
*/
view
/**
* @private
* @type {boolean}
*/
overflowWasHidden = false
/**
* @private
*/
onResizeBind
/**
* @private
* @type {ThemeManager}
*/
@inject(ThemeManager)
themeManager
/**
* @param {import('view').default} view
*/
constructor(view) {
this.view = view;
view.listenToOnce(view, 'remove', () => {
window.removeEventListener('resize', this.onResizeBind)
if (this.overflowWasHidden) {
document.body.style.overflow = 'unset';
this.overflowWasHidden = false;
}
});
this.onResizeBind = this.onResize.bind(this);
window.addEventListener('resize', this.onResizeBind);
this.navbarPanelHeightSpace = this.themeManager.getParam('navbarPanelHeightSpace') ?? 100;
this.navbarPanelBodyMaxHeight = this.themeManager.getParam('navbarPanelBodyMaxHeight') ?? 600;
this.xsWidth = this.themeManager.getParam('screenWidthXs');
this.onResize();
}
/**
* @private
*/
onResize() {
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
const panelBody = this.view.element?.querySelector('.panel-body');
const heading = this.view.element?.querySelector('.panel-heading');
if (!(panelBody instanceof HTMLElement)) {
return;
}
const diffHeight = heading?.outerHeight ?? 0;
const cssParams = {};
if (windowWidth <= this.xsWidth) {
cssParams.height = (windowHeight - diffHeight) + 'px';
cssParams.overflow = 'auto';
document.body.style.overflow = 'hidden';
this.overflowWasHidden = true;
} else {
cssParams.height = 'unset';
cssParams.overflow = 'none';
if (this.overflowWasHidden) {
panelBody.style.overflow = 'unset';
this.overflowWasHidden = false;
}
if (windowHeight - this.navbarPanelBodyMaxHeight < this.navbarPanelHeightSpace) {
const maxHeight = windowHeight - this.navbarPanelHeightSpace;
cssParams.maxHeight = maxHeight + 'px';
}
}
for (const [param, value] of Object.entries(cssParams)) {
panelBody.style[param] = value;
}
}
}

View File

@@ -0,0 +1,157 @@
/************************************************************************
* 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.
************************************************************************/
/**
* A debounce helper.
*
* @since 9.1.0
*/
export default class DebounceHelper {
/**
* @type {boolean}
* @private
*/
blocked = false
/**
* @type {boolean}
* @private
*/
blockedInProcess = false
/**
* @type {boolean}
* @private
*/
calledWhenProcessBlocked = false
/**
* @type {number}
* @private
*/
interval = 500
/**
* @type {number}
* @private
*/
blockInterval = 1000
/**
* @type {number}
* @private
*/
blockedCallCount = 0
/**
* @type {number|null}
* @private
*/
blockTimeoutId = null
/**
* @param {{
* handler: function(...*),
* interval?: number,
* blockInterval?: number,
* }} options
* @param options
*/
constructor(options) {
/**
* @private
* @type {function(...*)}
*/
this.handler = options.handler;
this.interval = options.interval ?? this.interval;
this.blockInterval = options.blockInterval ?? this.blockInterval;
}
/**
* Process.
*
* @param {...*} [arguments]
*/
process() {
const handle = () => {
if (this.blocked) {
this.blockedCallCount ++;
return;
}
if (this.blockedInProcess) {
this.calledWhenProcessBlocked = true;
return;
}
this.handler(arguments);
this.blockedInProcess = true;
setTimeout(() => {
const reRun = this.calledWhenProcessBlocked;
this.blockedInProcess = false;
this.calledWhenProcessBlocked = false;
if (reRun) {
handle();
}
}, this.interval);
};
handle();
}
/**
* Block for a while.
*
* @since 9.2.0
*/
block() {
this.blocked = true;
if (this.blockTimeoutId) {
clearTimeout(this.blockTimeoutId);
}
this.blockTimeoutId = setTimeout(() => {
this.blocked = false;
const toProcess = this.blockedCallCount > 1;
this.blockedCallCount = 0;
if (toProcess) {
this.process();
}
}, this.blockInterval)
}
}