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,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;