diff --git a/application/Espo/Classes/RecordHooks/Email/BeforeSave.php b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php index 7fabf2301e..d318b60e89 100644 --- a/application/Espo/Classes/RecordHooks/Email/BeforeSave.php +++ b/application/Espo/Classes/RecordHooks/Email/BeforeSave.php @@ -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 diff --git a/application/Espo/Core/Utils/Metadata/AdditionalBuilder/LogicDefsBc.php b/application/Espo/Core/Utils/Metadata/AdditionalBuilder/LogicDefsBc.php index ff8d9b0ac9..88ec5c0fbe 100644 --- a/application/Espo/Core/Utils/Metadata/AdditionalBuilder/LogicDefsBc.php +++ b/application/Espo/Core/Utils/Metadata/AdditionalBuilder/LogicDefsBc.php @@ -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; } diff --git a/application/Espo/Modules/Crm/Resources/i18n/en_US/MassEmail.json b/application/Espo/Modules/Crm/Resources/i18n/en_US/MassEmail.json index 7a8d9e9d1d..397dcedf25 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/en_US/MassEmail.json +++ b/application/Espo/Modules/Crm/Resources/i18n/en_US/MassEmail.json @@ -52,7 +52,7 @@ "storeSentEmails": "Emails will be stored in CRM." }, "presetFilters": { - "actual": "Actual", + "actual": "Active", "complete": "Complete" } } diff --git a/application/Espo/ORM/QueryComposer/PostgresqlQueryComposer.php b/application/Espo/ORM/QueryComposer/PostgresqlQueryComposer.php index 3760e52263..9a1d71cf5b 100644 --- a/application/Espo/ORM/QueryComposer/PostgresqlQueryComposer.php +++ b/application/Espo/ORM/QueryComposer/PostgresqlQueryComposer.php @@ -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'"; } diff --git a/application/Espo/Resources/i18n/en_US/EmailTemplate.json b/application/Espo/Resources/i18n/en_US/EmailTemplate.json index 23547f7f63..763437ff76 100644 --- a/application/Espo/Resources/i18n/en_US/EmailTemplate.json +++ b/application/Espo/Resources/i18n/en_US/EmailTemplate.json @@ -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", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index aa754fcb36..cff358af14 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -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", diff --git a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json index c04b6a0f6b..21546c40d2 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailTemplate.json @@ -86,9 +86,9 @@ } }, "collection": { - "orderBy": "createdAt", - "order": "desc", - "textFilterFields": ["name", "subject"] + "orderBy": "name", + "order": "asc", + "textFilterFields": ["name"] }, "optimisticConcurrencyControl": true } diff --git a/client/modules/crm/src/views/lead/convert.js b/client/modules/crm/src/views/lead/convert.js index bd01225c86..32fd2ae689 100644 --- a/client/modules/crm/src/views/lead/convert.js +++ b/client/modules/crm/src/views/lead/convert.js @@ -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); diff --git a/client/res/templates/record/kanban-item.tpl b/client/res/templates/record/kanban-item.tpl index ffaeec783a..786689bd0a 100644 --- a/client/res/templates/record/kanban-item.tpl +++ b/client/res/templates/record/kanban-item.tpl @@ -1,4 +1,4 @@ -
+
{{#each layoutDataList}}
diff --git a/client/src/app.js b/client/src/app.js index 04795e03c5..0599caf2c1 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -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); } diff --git a/client/src/helpers/misc/foreign-field-params.js b/client/src/helpers/misc/foreign-field-params.js new file mode 100644 index 0000000000..68e4c98f3b --- /dev/null +++ b/client/src/helpers/misc/foreign-field-params.js @@ -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 . + * + * 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}`); + } +} diff --git a/client/src/model.js b/client/src/model.js index 9557c975c6..fdb4675664 100644 --- a/client/src/model.js +++ b/client/src/model.js @@ -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); } } }; diff --git a/client/src/view-helper.js b/client/src/view-helper.js index 9595cfcc86..5263942f97 100644 --- a/client/src/view-helper.js +++ b/client/src/view-helper.js @@ -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(/\ { text = text.replace(item.regex, item.value); }); diff --git a/client/src/views/admin/layouts/index.js b/client/src/views/admin/layouts/index.js index d8b9d09cc1..4ce3a333ef 100644 --- a/client/src/views/admin/layouts/index.js +++ b/client/src/views/admin/layouts/index.js @@ -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'])) { diff --git a/client/src/views/email/record/detail.js b/client/src/views/email/record/detail.js index 60fbb6114f..25cdf1c4b6 100644 --- a/client/src/views/email/record/detail.js +++ b/client/src/views/email/record/detail.js @@ -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(); diff --git a/client/src/views/email/record/list.js b/client/src/views/email/record/list.js index 61a69eba75..52ceac30e2 100644 --- a/client/src/views/email/record/list.js +++ b/client/src/views/email/record/list.js @@ -46,6 +46,12 @@ class EmailListRecordView extends ListRecordView { */ toRemoveIdList + collectionEventSyncList = [ + 'moving-to-trash', + 'retrieving-from-trash', + 'moving-to-archive', + ] + setup() { super.setup(); diff --git a/client/src/views/fields/address.js b/client/src/views/fields/address.js index 24cdb9cbea..0330b1d90e 100644 --- a/client/src/views/fields/address.js +++ b/client/src/views/fields/address.js @@ -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 = []; diff --git a/client/src/views/fields/assigned-users.js b/client/src/views/fields/assigned-users.js index 577a6f0e8a..853641bdb2 100644 --- a/client/src/views/fields/assigned-users.js +++ b/client/src/views/fields/assigned-users.js @@ -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) { diff --git a/client/src/views/fields/attachment-multiple.js b/client/src/views/fields/attachment-multiple.js index a8fb76eeec..21d7c41438 100644 --- a/client/src/views/fields/attachment-multiple.js +++ b/client/src/views/fields/attachment-multiple.js @@ -101,6 +101,12 @@ class AttachmentMultipleFieldView extends BaseFieldView { searchTypeList = ['isNotEmpty', 'isEmpty'] + /** + * @private + * @type {Object.} + */ + 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 $('') - .attr('src', this.getImageUrl(id, 'small')) + .attr('src', this.getImageUrl(id, size)) .attr('title', name) .attr('alt', name) .attr('draggable', 'false') diff --git a/client/src/views/fields/foreign-phone.js b/client/src/views/fields/foreign-phone.js index 4cfb10fbd1..4cdaea3af3 100644 --- a/client/src/views/fields/foreign-phone.js +++ b/client/src/views/fields/foreign-phone.js @@ -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; diff --git a/client/src/views/fields/phone.js b/client/src/views/fields/phone.js index 75dc6f5b42..867e51cd7f 100644 --- a/client/src/views/fields/phone.js +++ b/client/src/views/fields/phone.js @@ -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, ''); } diff --git a/client/src/views/modals/select-category-tree-records.js b/client/src/views/modals/select-category-tree-records.js index 609a3e37d7..d4c773d20b 100644 --- a/client/src/views/modals/select-category-tree-records.js +++ b/client/src/views/modals/select-category-tree-records.js @@ -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(); diff --git a/client/src/views/notification/badge.js b/client/src/views/notification/badge.js index 8284317f50..98e712d292 100644 --- a/client/src/views/notification/badge.js +++ b/client/src/views/notification/badge.js @@ -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)); } /** diff --git a/client/src/views/record/kanban-item.js b/client/src/views/record/kanban-item.js index 1acb7c96a1..a57a2e48f6 100644 --- a/client/src/views/record/kanban-item.js +++ b/client/src/views/record/kanban-item.js @@ -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 = []; diff --git a/client/src/views/record/kanban.js b/client/src/views/record/kanban.js index 99d18942f6..8f40ee2975 100644 --- a/client/src/views/record/kanban.js +++ b/client/src/views/record/kanban.js @@ -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); } diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index 58693dbe10..96efea1bb9 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -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) { diff --git a/client/src/web-socket-manager.js b/client/src/web-socket-manager.js index 21cd81adb8..1d10d7c9d2 100644 --- a/client/src/web-socket-manager.js +++ b/client/src/web-socket-manager.js @@ -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 { diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index c4b4b1b3ef..b3da135c9d 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -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 { diff --git a/package-lock.json b/package-lock.json index d2d343654d..7c9836b3cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 1879c74695..07e9a219ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "espocrm", - "version": "9.1.0", + "version": "9.1.1", "description": "Open-source CRM.", "repository": { "type": "git", diff --git a/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php b/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php index f44833b021..6f9bf7f2e9 100644 --- a/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php +++ b/tests/unit/Espo/ORM/PostgresqlQueryComposerTest.php @@ -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); + } }