Merge branch 'fix'

This commit is contained in:
Yuri Kuznetsov
2025-05-12 10:23:22 +03:00
31 changed files with 383 additions and 47 deletions

View File

@@ -34,7 +34,6 @@ use Espo\Core\Record\Hook\SaveHook;
use Espo\Entities\Email;
use Espo\ORM\Entity;
use Espo\Tools\Email\Util;
use League\HTMLToMarkdown\HtmlConverter;
/**
* @implements SaveHook<Email>

View File

@@ -103,7 +103,7 @@ class LogicDefsBc implements AdditionalBuilder
}
foreach ($keys as $key) {
if (!isset($dynamicLogic->$key)) {
if (!isset($dynamicLogic->$key) || !is_object($dynamicLogic->$key)) {
continue;
}

View File

@@ -52,7 +52,7 @@
"storeSentEmails": "Emails will be stored in CRM."
},
"presetFilters": {
"actual": "Actual",
"actual": "Active",
"complete": "Complete"
}
}

View File

@@ -192,6 +192,13 @@ class PostgresqlQueryComposer extends BaseQueryComposer
$offsetHoursString = substr($offsetHoursString, 1, -1);
}
if (str_contains($offsetHoursString, '.')) {
$minutes = (int) (floatval($offsetHoursString) * 60);
$minutesString = (string) $minutes;
return "$argumentPartList[0] + INTERVAL 'MINUTE $minutesString'";
}
return "$argumentPartList[0] + INTERVAL 'HOUR $offsetHoursString'";
}

View File

@@ -24,7 +24,7 @@
"oneOff": "Check if you are going to use this template only once. E.g. for Mass Email."
},
"presetFilters": {
"actual": "Actual"
"actual": "Active"
},
"placeholderTexts": {
"today": "Today's date",

View File

@@ -162,6 +162,7 @@
"Access": "Access",
"Timeout": "Timeout",
"No internet": "No internet",
"Network error": "Network error",
"Are you sure?": "Are you sure?",
"Record has been removed": "Record has been removed",
"Wrong username/password": "Wrong username/password",

View File

@@ -86,9 +86,9 @@
}
},
"collection": {
"orderBy": "createdAt",
"order": "desc",
"textFilterFields": ["name", "subject"]
"orderBy": "name",
"order": "asc",
"textFilterFields": ["name"]
},
"optimisticConcurrencyControl": true
}

View File

@@ -172,6 +172,8 @@ class ConvertLeadView extends MainView {
scopeList.forEach(scope => {
const editView = /** @type {import('views/record/edit').default} */this.getView(scope);
editView.setConfirmLeaveOut(false);
editView.model.set(editView.fetch());
notValid = editView.validate() || notValid;
});
@@ -196,7 +198,7 @@ class ConvertLeadView extends MainView {
this.getRouter().confirmLeaveOut = false;
this.getRouter().navigate('#Lead/view/' + this.model.id, {trigger: true});
this.notify('Converted', 'success');
Espo.Ui.notify(this.translate('Converted', 'labels', 'Lead'));
})
.catch(xhr => {
Espo.Ui.notify(false);

View File

@@ -1,4 +1,4 @@
<div class="panel panel-default">
<div class="panel panel-default {{#if isStarred}} starred {{~/if}} ">
<div class="panel-body">
{{#each layoutDataList}}
<div>

View File

@@ -1308,7 +1308,11 @@ class App {
let msg = '';
if (!label) {
msg += this.language.translate('Error') + ' ' + xhr.status;
if (xhr.status === 0) {
msg += this.language.translate('Network error');
} else {
msg += this.language.translate('Error') + ' ' + xhr.status;
}
} else {
msg += this.language.translate(label);
}

View File

@@ -0,0 +1,71 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import {inject} from 'di';
import Metadata from 'metadata';
/**
* @since 9.1.1
* @internal For future use.
*/
export default class {
/**
* @type {Metadata}
* @private
*/
@inject(Metadata)
metadata
/**
* Get foreign field params.
*
* @param {string} entityType
* @param {string} field
* @return {Record|null}
*/
get(entityType, field) {
/** @type {Record|null} */
const params = this.metadata.get(`entityDefs.${entityType}.fields.${field}`);
if (!params) {
return null;
}
const foreignField = params.field;
const link = params.link;
const foreignEntityType = this.metadata.get(`entityDefs.${entityType}.links.${link}.entity`);
if (!foreignEntityType) {
return null;
}
return this.metadata.get(`entityDefs.${foreignEntityType}.links.${foreignField}`);
}
}

View File

@@ -612,9 +612,11 @@ class Model {
const success = options.success;
const collection = this.collection;
const destroy = () => {
this.stopListening();
this.trigger('destroy', this, this.collection, options);
this.trigger('destroy', this, collection, options);
};
options.success = response => {
@@ -633,8 +635,8 @@ class Model {
this.trigger('sync', this, response, syncOptions);
if (this.collection) {
this.collection.trigger('model-sync', this, syncOptions);
if (collection) {
collection.trigger('model-sync', this, syncOptions);
}
}
};

View File

@@ -28,7 +28,7 @@
/** @module view-helper */
import {marked} from 'marked';
import {marked, Lexer} from 'marked';
import DOMPurify from 'dompurify';
import Handlebars from 'handlebars';
@@ -64,6 +64,46 @@ class ViewHelper {
headerIds: false,
});
// @todo Review after updating the marked library.
marked.use({
tokenizer: {
tag(src) {
const cap = Lexer.rules.inline.tag.exec(src);
if (!cap) {
return;
}
return {
type: 'text',
raw: cap[0],
inLink: this.lexer.state.inLink,
inRawBlock: this.lexer.state.inRawBlock,
text: Handlebars.Utils.escapeExpression(cap[0]),
};
},
html(src) {
const cap = Lexer.rules.block.html.exec(src);
if (!cap) {
return;
}
const token = {
type: 'paragraph',
raw: cap[0],
pre: (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'),
text: Handlebars.Utils.escapeExpression(cap[0]),
};
token.tokens = [];
this.lexer.inline(token.text, token.tokens);
return token;
},
},
});
DOMPurify.addHook('beforeSanitizeAttributes', function (node) {
if (node instanceof HTMLAnchorElement) {
if (node.getAttribute('target')) {
@@ -742,9 +782,6 @@ class ViewHelper {
transformMarkdownText(text, options) {
text = text || '';
// noinspection RegExpRedundantEscape
text = text.replace(/\</g, '\\<');
this.mdBeforeList.forEach(item => {
text = text.replace(item.regex, item.value);
});

View File

@@ -41,6 +41,7 @@ class LayoutIndexView extends View {
'detail',
'listSmall',
'detailSmall',
'defaultSidePanel',
'bottomPanelsDetail',
'filters',
'massUpdate',
@@ -435,10 +436,10 @@ class LayoutIndexView extends View {
}
if (
!this.getMetadata().get(['clientDefs', scope, 'defaultSidePanelDisabled']) &&
!this.getMetadata().get(['clientDefs', scope, 'defaultSidePanelFieldList'])
this.getMetadata().get(['clientDefs', scope, 'defaultSidePanelDisabled']) ||
this.getMetadata().get(['clientDefs', scope, 'defaultSidePanelFieldList'])
) {
typeList.push('defaultSidePanel');
typeList = typeList.filter(it => it !== 'defaultSidePanel');
}
if (this.getMetadata().get(['clientDefs', scope, 'kanbanViewMode'])) {

View File

@@ -101,12 +101,12 @@ class EmailDetailRecordView extends DetailRecordView {
this.listenTo(this.model, 'change:status', () => this.controlSendButton());
if (this.model.get('status') !== 'Draft' && this.model.has('isRead') && !this.model.get('isRead')) {
this.model.set('isRead', true);
this.model.set('isRead', true, {sync: true});
}
this.listenTo(this.model, 'sync', () => {
if (!this.model.get('isRead') && this.model.get('status') !== 'Draft') {
this.model.set('isRead', true);
this.model.set('isRead', true, {sync: true});
}
});
@@ -303,13 +303,13 @@ class EmailDetailRecordView extends DetailRecordView {
actionMarkAsImportant() {
Espo.Ajax.postRequest('Email/inbox/important', {id: this.model.id});
this.model.set('isImportant', true);
this.model.set('isImportant', true, {sync: true});
}
actionMarkAsNotImportant() {
Espo.Ajax.deleteRequest('Email/inbox/important', {id: this.model.id});
this.model.set('isImportant', false);
this.model.set('isImportant', false, {sync: true});
}
actionMoveToTrash() {
@@ -318,9 +318,9 @@ class EmailDetailRecordView extends DetailRecordView {
});
if (this.model.attributes.groupFolderId) {
this.model.set('groupStatusFolder', 'Trash');
this.model.set('groupStatusFolder', 'Trash', {sync: true});
} else {
this.model.set('inTrash', true);
this.model.set('inTrash', true, {sync: true});
}
if (this.model.collection) {
@@ -334,10 +334,10 @@ class EmailDetailRecordView extends DetailRecordView {
Espo.Ui.warning(this.translate('Retrieved from Trash', 'labels', 'Email'));
});
this.model.set('inTrash', false);
this.model.set('inTrash', false, {sync: true});
if (this.model.attributes.groupFolderId) {
this.model.set('groupStatusFolder', null);
this.model.set('groupStatusFolder', null, {sync: true});
}
if (this.model.collection) {
@@ -406,8 +406,8 @@ class EmailDetailRecordView extends DetailRecordView {
Espo.Ajax.postRequest(`Email/inbox/folders/archive`, {id: this.model.id})
.then(() => {
this.model.attributes.groupFolderId ?
this.model.set('groupStatusFolder', 'Archive') :
this.model.set('inArchive', true);
this.model.set('groupStatusFolder', 'Archive', {sync: true}) :
this.model.set('inArchive', true, {sync: true});
Espo.Ui.info(this.translate('Moved to Archive', 'labels', 'Email'));
@@ -576,7 +576,7 @@ class EmailDetailRecordView extends DetailRecordView {
actionSend() {
this.send()
.then(() => {
this.model.set('status', 'Sent');
this.model.set('status', 'Sent', {sync: true});
if (this.mode !== this.MODE_DETAIL) {
this.setDetailMode();

View File

@@ -46,6 +46,12 @@ class EmailListRecordView extends ListRecordView {
*/
toRemoveIdList
collectionEventSyncList = [
'moving-to-trash',
'retrieving-from-trash',
'moving-to-archive',
]
setup() {
super.setup();

View File

@@ -469,7 +469,14 @@ class AddressFieldView extends BaseFieldView {
setup() {
super.setup();
const actualAttributePartList = this.getMetadata().get(['fields', this.type, 'actualFields']) || [];
const actualAttributePartList = this.getMetadata().get(['fields', this.type, 'actualFields']) ||
[
'street',
'city',
'state',
'country',
'postalCode',
];
this.addressAttributeList = [];
this.addressPartList = [];

View File

@@ -53,7 +53,7 @@ class AssignedUsersFieldView extends LinkMultipleFieldView {
getDetailLinkHtml(id, name) {
const html = super.getDetailLinkHtml(id);
const avatarHtml = this.isDetailMode() ?
const avatarHtml = this.isDetailMode() || this.isListMode() ?
this.getHelper().getAvatarHtml(id, 'small', 18, 'avatar-link') : '';
if (!avatarHtml) {

View File

@@ -101,6 +101,12 @@ class AttachmentMultipleFieldView extends BaseFieldView {
searchTypeList = ['isNotEmpty', 'isEmpty']
/**
* @private
* @type {Object.<string, true>}
*/
uploadedIdMap
events = {
/** @this AttachmentMultipleFieldView */
'click a.remove-attachment': function (e) {
@@ -275,6 +281,8 @@ class AttachmentMultipleFieldView extends BaseFieldView {
if (this.resizeIsBeingListened) {
$(window).off('resize.' + this.cid);
}
this.uploadedIdMap = {};
});
this.on('inline-edit-off', () => {
@@ -290,6 +298,8 @@ class AttachmentMultipleFieldView extends BaseFieldView {
this.uploadFiles(files);
});
}
this.uploadedIdMap = {};
}
setupSearch() {
@@ -434,6 +444,8 @@ class AttachmentMultipleFieldView extends BaseFieldView {
nameHash[attachment.id] = attachment.get('name');
this.model.set(this.nameHashName, nameHash, {ui: ui});
this.uploadedIdMap[attachment.id] = true;
}
/**
@@ -448,9 +460,11 @@ class AttachmentMultipleFieldView extends BaseFieldView {
return null;
}
const size = (id in this.uploadedIdMap) ? undefined : 'small';
// noinspection HtmlRequiredAltAttribute,RequiredAttributes
return $('<img>')
.attr('src', this.getImageUrl(id, 'small'))
.attr('src', this.getImageUrl(id, size))
.attr('title', name)
.attr('alt', name)
.attr('draggable', 'false')

View File

@@ -32,6 +32,13 @@ class ForeignPhoneFieldView extends PhoneFieldView {
type = 'foreign'
readOnly = true
setup() {
super.setup();
// Numeric search does not work for foreign.
this.isNumeric = false;
}
}
export default ForeignPhoneFieldView;

View File

@@ -86,6 +86,12 @@ class PhoneFieldView extends VarcharFieldView {
*/
validationRegExp
/**
* @protected
* @type {boolean}
*/
isNumeric
events = {
/** @this PhoneFieldView */
'click [data-action="switchPhoneProperty"]': function (e) {
@@ -696,6 +702,8 @@ class PhoneFieldView extends VarcharFieldView {
});
this.validations.push(() => this.validateMaxCount());
this.isNumeric = this.getConfig().get('phoneNumberNumericSearch');
}
/**
@@ -819,9 +827,7 @@ class PhoneFieldView extends VarcharFieldView {
fetchSearch() {
const type = this.fetchSearchType() || 'startsWith';
const isNumeric = this.getConfig().get('phoneNumberNumericSearch');
const name = isNumeric ?
const name = this.isNumeric ?
this.name + 'Numeric' :
this.name;
@@ -852,7 +858,7 @@ class PhoneFieldView extends VarcharFieldView {
const originalValue = value;
if (isNumeric && value) {
if (this.isNumeric && value) {
value = value.replace(/[^0-9]/g, '');
}

View File

@@ -145,11 +145,11 @@ class SelectCategoryTreeRecordsModalView extends SelectRecordsModalView {
checkAllResultDisabled: true,
buttonsDisabled: true,
}, listView => {
listView.once('select', model => {
this.trigger('select', model);
listView.once('select', models => {
this.trigger('select', models);
if (this.options.onSelect) {
this.options.onSelect([model]);
this.options.onSelect(models);
}
this.close();

View File

@@ -304,7 +304,13 @@ class NotificationBadgeView extends View {
}, this.waitInterval * 1000);
};
this.getHelper().webSocketManager.subscribe('newNotification', () => onWebSocketNewNotification());
const webSocketManager = this.getHelper().webSocketManager;
webSocketManager.subscribe('newNotification', () => onWebSocketNewNotification());
webSocketManager.subscribeToReconnect(onWebSocketNewNotification);
this.once('remove', () => webSocketManager.unsubscribe('newNotification'));
this.once('remove', () => webSocketManager.unsubscribeFromReconnect(onWebSocketNewNotification));
}
/**

View File

@@ -36,6 +36,7 @@ class KanbanRecordItem extends View {
return {
layoutDataList: this.layoutDataList,
rowActionsDisabled: this.rowActionsDisabled,
isStarred: this.hasStars && this.model.attributes.isStarred,
};
}
@@ -43,6 +44,7 @@ class KanbanRecordItem extends View {
this.itemLayout = this.options.itemLayout;
this.rowActionsView = this.options.rowActionsView;
this.rowActionsDisabled = this.options.rowActionsDisabled;
this.hasStars = this.options.hasStars;
this.layoutDataList = [];

View File

@@ -433,6 +433,12 @@ class KanbanRecordView extends ListRecordView {
this.wait(
this.getHelper().processSetupHandlers(this, 'record/kanban')
);
/**
* @private
* @type {boolean}
*/
this.hasStars = this.getMetadata().get(`scopes.${this.entityType}.stars`) || false;
}
afterRender() {
@@ -1026,6 +1032,7 @@ class KanbanRecordView extends ListRecordView {
moveOverRowAction: this.moveOverRowAction,
additionalRowActionList: this._additionalRowActionList,
scope: this.scope,
hasStars: this.hasStars,
}, callback);
}

View File

@@ -599,6 +599,13 @@ class ListRecordView extends View {
*/
_columnResizeHelper
/**
* @protected
* @type {string[]}
* @since 9.1.1
*/
collectionEventSyncList
/** @inheritDoc */
events = {
/**
@@ -1046,6 +1053,12 @@ class ListRecordView extends View {
this.headerDisabled = this.options.headerDisabled || this.headerDisabled;
this.noDataDisabled = this.options.noDataDisabled || this.noDataDisabled;
if (!this.collectionEventSyncList) {
this.collectionEventSyncList = [];
} else {
this.collectionEventSyncList = [...this.collectionEventSyncList];
}
if (!this.headerDisabled) {
this.header = _.isUndefined(this.options.header) ? this.header : this.options.header;
} else {
@@ -2177,7 +2190,19 @@ class ListRecordView extends View {
model: collection.get(id),
};
if (this.collectionEventSyncList) {
this.listenTo(collection, 'all', (event, ...parameters) => {
if (this.collectionEventSyncList.includes(event)) {
this.collection.trigger(event, ...parameters);
}
});
}
this.listenTo(collection, 'model-sync', (/** Model */m, /** Record */o) => {
if (o.action === 'destroy') {
this.removeRecordFromList(m.id);
}
const model = this.collection.get(m.id);
if (!model) {

View File

@@ -62,6 +62,12 @@ class WebSocketManager {
*/
this.config = config;
/**
* @private
* @type {Function[]}
*/
this.subscribeToReconnectQueue = [];
/**
* @private
* @type {{category: string, callback: Function}[]}
@@ -171,6 +177,8 @@ class WebSocketManager {
* @param {string} url
*/
connectInternal(auth, userId, url) {
let wasConnected = false;
this.connection = new ab.Session(
url,
() => {
@@ -182,7 +190,13 @@ class WebSocketManager {
this.subscribeQueue = [];
if (wasConnected) {
this.subscribeToReconnectQueue.forEach(callback => callback());
}
this.schedulePing();
wasConnected = true;
},
code => {
if (code === ab.CONNECTION_CLOSED) {
@@ -204,6 +218,26 @@ class WebSocketManager {
);
}
/**
* Subscribe to reconnecting.
*
* @param {function(): void} callback A callback.
* @since 9.1.1
*/
subscribeToReconnect(callback) {
this.subscribeToReconnectQueue.push(callback);
}
/**
* Unsubscribe from reconnecting.
*
* @param {function(): void} callback A callback.
* @since 9.1.1
*/
unsubscribeFromReconnect(callback) {
this.subscribeToReconnectQueue = this.subscribeToReconnectQueue.filter(it => it !== callback);
}
/**
* Subscribe to a topic.
*
@@ -254,11 +288,19 @@ class WebSocketManager {
}
this.subscribeQueue = this.subscribeQueue.filter(item => {
return item.category !== category && item.callback !== callback;
if (callback === undefined) {
return item.category !== category;
}
return item.category !== category || item.callback !== callback;
});
this.subscriptions = this.subscriptions.filter(item => {
return item.category !== category && item.callback !== callback;
if (callback === undefined) {
return item.category !== category;
}
return item.category !== category || item.callback !== callback;
});
try {

View File

@@ -1411,6 +1411,27 @@ section {
}
}
.list-kanban {
.panel.starred {
> .panel-body {
position: relative;
&::before {
content: " ";
inset: 0;
position: absolute;
left: 0;
top: calc(50% - var(--8px));
height: var(--16px);
width: var(--3px);
background-color: var(--brand-warning);
border-top-right-radius: var(--2px);
border-bottom-right-radius: var(--2px);
}
}
}
}
.list > {
table > tbody,
ul.list-group {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "espocrm",
"version": "9.1.0",
"version": "9.1.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "espocrm",
"version": "9.0.8",
"version": "9.1.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "espocrm",
"version": "9.1.0",
"version": "9.1.1",
"description": "Open-source CRM.",
"repository": {
"type": "git",

View File

@@ -278,4 +278,73 @@ class PostgresqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$this->assertEquals($expectedSql, $sql);
}
public function testSelectFunctionTz1()
{
$query = SelectBuilder::create()
->from('Comment')
->select(
Expression::convertTimezone(
Expression::column('createdAt'),
5.5
),
'createdAt'
)
->withDeleted()
->build();
$sql = $this->queryComposer->composeSelect($query);
$expectedSql =
'SELECT "comment"."created_at" + INTERVAL \'MINUTE 330\' AS "createdAt" ' .
'FROM "comment"';
$this->assertEquals($expectedSql, $sql);
}
public function testSelectFunctionTz2()
{
$query = SelectBuilder::create()
->from('Comment')
->select(
Expression::convertTimezone(
Expression::column('createdAt'),
-5.5
),
'createdAt'
)
->withDeleted()
->build();
$sql = $this->queryComposer->composeSelect($query);
$expectedSql =
'SELECT "comment"."created_at" + INTERVAL \'MINUTE -330\' AS "createdAt" ' .
'FROM "comment"';
$this->assertEquals($expectedSql, $sql);
}
public function testSelectFunctionTz3()
{
$query = SelectBuilder::create()
->from('Comment')
->select(
Expression::convertTimezone(
Expression::column('createdAt'),
5
),
'createdAt'
)
->withDeleted()
->build();
$sql = $this->queryComposer->composeSelect($query);
$expectedSql =
'SELECT "comment"."created_at" + INTERVAL \'HOUR 5\' AS "createdAt" ' .
'FROM "comment"';
$this->assertEquals($expectedSql, $sql);
}
}