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
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:
252
client/src/helpers/action-item-setup.js
Normal file
252
client/src/helpers/action-item-setup.js
Normal 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;
|
||||
75
client/src/helpers/assignment.js
Normal file
75
client/src/helpers/assignment.js
Normal 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';
|
||||
}
|
||||
}
|
||||
110
client/src/helpers/export.js
Normal file
110
client/src/helpers/export.js
Normal 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;
|
||||
349
client/src/helpers/field/cascade-links.js
Normal file
349
client/src/helpers/field/cascade-links.js
Normal 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`);
|
||||
}
|
||||
}
|
||||
112
client/src/helpers/field/link-icon.js
Normal file
112
client/src/helpers/field/link-icon.js
Normal 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);
|
||||
}
|
||||
}
|
||||
106
client/src/helpers/field/options-provider.js
Normal file
106
client/src/helpers/field/options-provider.js
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
197
client/src/helpers/file-upload.js
Normal file
197
client/src/helpers/file-upload.js
Normal 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;
|
||||
356
client/src/helpers/list/misc/list-tree-draggable.js
Normal file
356
client/src/helpers/list/misc/list-tree-draggable.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
229
client/src/helpers/list/misc/sticky-bar.js
Normal file
229
client/src/helpers/list/misc/sticky-bar.js
Normal 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;
|
||||
113
client/src/helpers/list/select-provider.js
Normal file
113
client/src/helpers/list/select-provider.js
Normal 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;
|
||||
306
client/src/helpers/list/settings.js
Normal file
306
client/src/helpers/list/settings.js
Normal 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;
|
||||
142
client/src/helpers/mass-action.js
Normal file
142
client/src/helpers/mass-action.js
Normal 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;
|
||||
162
client/src/helpers/misc/attachment-insert-from-source.js
Normal file
162
client/src/helpers/misc/attachment-insert-from-source.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
240
client/src/helpers/misc/authentication-provider.js
Normal file
240
client/src/helpers/misc/authentication-provider.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
118
client/src/helpers/misc/field-language.js
Normal file
118
client/src/helpers/misc/field-language.js
Normal 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;
|
||||
71
client/src/helpers/misc/foreign-field-params.js
Normal file
71
client/src/helpers/misc/foreign-field-params.js
Normal 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}`);
|
||||
}
|
||||
}
|
||||
96
client/src/helpers/misc/foreign-field.js
Normal file
96
client/src/helpers/misc/foreign-field.js
Normal 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;
|
||||
}
|
||||
}
|
||||
142
client/src/helpers/misc/list-select-attributes.js
Normal file
142
client/src/helpers/misc/list-select-attributes.js
Normal 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);
|
||||
}
|
||||
}
|
||||
159
client/src/helpers/misc/mailto.js
Normal file
159
client/src/helpers/misc/mailto.js
Normal 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;
|
||||
34
client/src/helpers/misc/o-auth-client-secret.ts
Normal file
34
client/src/helpers/misc/o-auth-client-secret.ts
Normal 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};
|
||||
76
client/src/helpers/misc/pipelines.js
Normal file
76
client/src/helpers/misc/pipelines.js
Normal 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;
|
||||
}
|
||||
}
|
||||
93
client/src/helpers/misc/reactions.js
Normal file
93
client/src/helpers/misc/reactions.js
Normal 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;
|
||||
142
client/src/helpers/misc/stored-text-search.js
Normal file
142
client/src/helpers/misc/stored-text-search.js
Normal 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);
|
||||
}
|
||||
}
|
||||
999
client/src/helpers/misc/summernote-custom.js
Normal file
999
client/src/helpers/misc/summernote-custom.js
Normal 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> </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> </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};
|
||||
84
client/src/helpers/model/default-value-provider.js
Normal file
84
client/src/helpers/model/default-value-provider.js
Normal 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;
|
||||
}
|
||||
}
|
||||
361
client/src/helpers/model/defaults-populator.js
Normal file
361
client/src/helpers/model/defaults-populator.js
Normal 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;
|
||||
364
client/src/helpers/record-modal.js
Normal file
364
client/src/helpers/record-modal.js
Normal 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;
|
||||
109
client/src/helpers/record/create-related.js
Normal file
109
client/src/helpers/record/create-related.js
Normal 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;
|
||||
376
client/src/helpers/record/detail/layout-converter.js
Normal file
376
client/src/helpers/record/detail/layout-converter.js
Normal 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;
|
||||
237
client/src/helpers/record/list/column-resize.js
Normal file
237
client/src/helpers/record/list/column-resize.js
Normal 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;
|
||||
}
|
||||
}
|
||||
214
client/src/helpers/record/list/column-width-control.js
Normal file
214
client/src/helpers/record/list/column-width-control.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
149
client/src/helpers/record/misc/sticky-bar.js
Normal file
149
client/src/helpers/record/misc/sticky-bar.js
Normal 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(' ')
|
||||
.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;
|
||||
229
client/src/helpers/record/select-related.js
Normal file
229
client/src/helpers/record/select-related.js
Normal 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;
|
||||
94
client/src/helpers/reg-exp-pattern.js
Normal file
94
client/src/helpers/reg-exp-pattern.js
Normal 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;
|
||||
58
client/src/helpers/site/modal-bar-provider.js
Normal file
58
client/src/helpers/site/modal-bar-provider.js
Normal 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;
|
||||
}
|
||||
}
|
||||
151
client/src/helpers/site/shortcut-manager.js
Normal file
151
client/src/helpers/site/shortcut-manager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
216
client/src/helpers/site/tabs.js
Normal file
216
client/src/helpers/site/tabs.js
Normal 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;
|
||||
}
|
||||
}
|
||||
131
client/src/helpers/site/window-panel-helper.js
Normal file
131
client/src/helpers/site/window-panel-helper.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
157
client/src/helpers/util/debounce.js
Normal file
157
client/src/helpers/util/debounce.js
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user