diff --git a/application/Espo/Resources/i18n/en_US/DashletOptions.json b/application/Espo/Resources/i18n/en_US/DashletOptions.json
index 9611e9ce36..6fa696cb8f 100644
--- a/application/Espo/Resources/i18n/en_US/DashletOptions.json
+++ b/application/Espo/Resources/i18n/en_US/DashletOptions.json
@@ -41,5 +41,9 @@
},
"tooltips": {
"skipOwn": "Actions made by your user account won't be displayed."
+ },
+ "otherFields": {
+ "soft": "Soft Color",
+ "small": "Small Font"
}
}
diff --git a/client/src/ui/multi-select.js b/client/src/ui/multi-select.js
index eaab75c8b3..0bb6475ca3 100644
--- a/client/src/ui/multi-select.js
+++ b/client/src/ui/multi-select.js
@@ -33,7 +33,7 @@ import Selectize from 'lib!selectize';
/**
* @typedef module:ui/multi-select~Options
* @type {Object}
- * @property {{value: string, text: string, style?: string}[]} items
+ * @property {{value: string, text: string, style?: string, small?: boolean}[]} items
* @property {string} [delimiter=':,:']
* @property {boolean} [restoreOnBackspace=false]
* @property {boolean} [removeButton=true]
@@ -165,11 +165,20 @@ const MultiSelect = {
...o,
}), {});
+ const classMap = {};
+
+ (options.items || []).forEach(it => {
+ if (it.small) {
+ classMap[it.value] = 'small';
+ }
+ });
+
selectizeOptions.render.item = (/** {text: string, value: string} */data, escape) => {
const text = escape(data.text);
const style = escape(styleMap[data.value] || '');
+ const classString = escape(classMap[data.value] || '');
- return `
${text} ` +
+ return `
`;
};
diff --git a/client/src/views/dashlets/fields/records/expanded-layout.js b/client/src/views/dashlets/fields/records/expanded-layout.js
index a74fd7b7a7..59dcb7ca45 100644
--- a/client/src/views/dashlets/fields/records/expanded-layout.js
+++ b/client/src/views/dashlets/fields/records/expanded-layout.js
@@ -28,44 +28,144 @@
import BaseFieldView from 'views/fields/base';
import MultiSelect from 'ui/multi-select';
+import ExpandedLayoutEditItemModalFieldView from 'views/dashlets/fields/records/expanded-layout/modals/edit-item';
class ExpandedLayoutDashletFieldView extends BaseFieldView {
// language=Handlebars
editTemplateContent = `
-
+
+ {{#each rowDataList}}
+
+
+
+
+ {{#if hasEdit}}
+
+
+
+
+ {{/if}}
+
+ {{/each}}
+
`
delimiter = ':,:'
+ /**
+ * @private
+ * @type {string}
+ */
+ targetEntityType
+
+ data() {
+ const rowList = this.getRowList();
+
+ const dataList = [...rowList, []].map((it, i) => ({
+ index: i,
+ value: it.map(subIt => subIt.name).join(this.delimiter),
+ hasEdit: i < rowList.length,
+ itemList: it.map(subIt => ({
+ name: subIt.name,
+ label: this.translate(subIt.name, 'fields', this.targetEntityType),
+ })),
+ }));
+
+ return {
+ rowDataList: dataList,
+ }
+ }
+
setup() {
this.addHandler('change', 'div[data-role="layoutRow"] input', () => {
- this.trigger('change');
- this.reRender();
+ setTimeout(() => {
+ this.trigger('change');
+ this.reRender();
+ }, 1);
});
+
+ this.addActionHandler('editItem', (event, target) => this.editItem(target.dataset.name));
+
+ this.targetEntityType = this.model.get('entityType') ||
+ this.getMetadata().get(['dashlets', this.dataObject.dashletName, 'entityType']);
+ }
+
+ /**
+ * @private
+ * @return {Array.<{name: string, link?: boolean, soft?: boolean, small?: boolean}>[]}
+ */
+ getRowList() {
+ return Espo.Utils.cloneDeep((this.model.get(this.name) || {}).rows || []);
}
afterRenderEdit() {
- const containerElement = this.element.querySelector(`:scope > .layout-container`);
-
- let rowList = (this.model.get(this.name) || {}).rows || [];
-
- rowList = Espo.Utils.cloneDeep(rowList);
+ const rowList = Espo.Utils.cloneDeep(this.getRowList());
rowList.push([]);
const fieldDataList = this.getFieldDataList();
rowList.forEach((row, i) => {
- const rowElement = this.createRowElement(row, i);
+ const usedOtherList = [];
+ const usedList = [];
- containerElement.append(rowElement);
+ rowList.forEach((it, j) => {
+ usedList.push(...it.map(it => it.name));
- const inputElement = rowElement.querySelector('input');
+ if (j === i) {
+ return;
+ }
+
+ usedOtherList.push(...it.map(it => it.name));
+ });
+
+ const preparedList = fieldDataList
+ .filter(it => !usedOtherList.includes(it.value))
+ .map(it => {
+ if (!usedList.includes(it.value)) {
+ return it;
+ }
+
+ const itemData = this.getItemData(it.value) || {};
+
+ if (itemData.soft) {
+ it.style = 'soft';
+ }
+
+ if (itemData.small) {
+ it.small = true;
+ }
+
+ return it;
+ });
+
+ const inputElement = this.element.querySelector(`input[data-index="${i.toString()}"]`);
/** @type {module:ui/multi-select~Options} */
const multiSelectOptions = {
- items: fieldDataList,
+ items: preparedList,
delimiter: this.delimiter,
matchAnyWord: this.matchAnyWord,
draggable: true,
@@ -75,34 +175,6 @@ class ExpandedLayoutDashletFieldView extends BaseFieldView {
});
}
- /**
- * @private
- * @param {Record[]} row
- * @param {number} i
- * @return {HTMLDivElement}
- */
- createRowElement(row, i) {
- row = row || [];
-
- const list = [];
-
- row.forEach(item => {
- list.push(item.name);
- });
-
- const div = document.createElement('div');
- div.dataset.role = 'layoutRow';
-
- const input = document.createElement('input');
- input.type = 'text';
- input.classList.add('row-' + i.toString());
- input.value = list.join(this.delimiter);
-
- div.append(input);
-
- return div;
- }
-
/**
* @private
* @return {{value: string, text: string}[]}
@@ -166,14 +238,70 @@ class ExpandedLayoutDashletFieldView extends BaseFieldView {
return dataList;
}
- fetch() {
- const value = {
- rows: [],
- };
+ /**
+ * @private
+ * @param {string} name
+ */
+ async editItem(name) {
+ const inputData = this.getItemData(name);
- this.$el.find('input').each((i, el) => {
+ const view = new ExpandedLayoutEditItemModalFieldView({
+ label: this.translate(name, 'fields', this.targetEntityType),
+ data: inputData,
+ onApply: data => this.applyItem(name, data),
+ });
+
+ await this.assignView('modal', view);
+ await view.render();
+ }
+
+ /**
+ * @private
+ * @param {string} name
+ * @return {{
+ * soft: boolean,
+ * small: boolean,
+ * }}
+ */
+ getItemData(name) {
+ /**
+ * @type {{
+ * soft: boolean,
+ * small: boolean,
+ * }}
+ */
+ let inputData;
+
+ for (const row of this.getRowList()) {
+ for (const item of row) {
+ if (item.name === name) {
+ inputData = {
+ soft: item.soft || false,
+ small: item.small || false,
+ };
+ }
+ }
+ }
+
+ return inputData;
+ }
+
+ fetch() {
+ const value = {rows: []};
+
+ /** @type {Record.
} */
+ const params = {};
+
+ for (const row of this.getRowList()) {
+ for (const item of row) {
+ params[item.name] = item;
+ }
+ }
+
+ this.element.querySelectorAll('input').forEach(/** HTMLInputElement*/inputElement => {
const row = [];
- let list = ($(el).val() || '').split(this.delimiter);
+
+ let list = inputElement.value.split(this.delimiter);
if (list.length === 1 && list[0] === '') {
list = [];
@@ -183,24 +311,47 @@ class ExpandedLayoutDashletFieldView extends BaseFieldView {
return;
}
- list.forEach(item => {
- const o = {name: item};
+ list.forEach(name => {
+ const item = {name: name};
- if (item === 'name') {
- o.link = true;
+ if (name === 'name') {
+ item.link = true;
}
- row.push(o);
+ if (params[name]) {
+ item.soft = params[name].soft || false;
+ item.small = params[name].small || false;
+ }
+
+ row.push(item);
});
value.rows.push(row);
});
- const data = {};
+ return {[this.name]: value};
+ }
- data[this.name] = value;
+ /**
+ * @private
+ * @param {string} name
+ * @param {{soft: boolean, small: boolean}} data
+ */
+ applyItem(name, data) {
+ const rowList = this.getRowList();
- return data;
+ for (const row of rowList) {
+ for (const item of row) {
+ if (item.name === name) {
+ item.soft = data.soft;
+ item.small = data.small;
+ }
+ }
+ }
+
+ this.model.set(this.name, {rows: rowList}, {ui: true});
+
+ this.reRender();
}
}
diff --git a/client/src/views/dashlets/fields/records/expanded-layout/modals/edit-item.js b/client/src/views/dashlets/fields/records/expanded-layout/modals/edit-item.js
new file mode 100644
index 0000000000..8a8d45c40b
--- /dev/null
+++ b/client/src/views/dashlets/fields/records/expanded-layout/modals/edit-item.js
@@ -0,0 +1,131 @@
+/************************************************************************
+ * 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 ModalView from 'views/modal';
+import EditForModalRecordView from 'views/record/edit-for-modal';
+import Model from 'model';
+import BoolFieldView from 'views/fields/bool';
+
+export default class ExpandedLayoutEditItemModalFieldView extends ModalView {
+
+ // language=Handlebars
+ templateContent = `
+ {{{record}}}
+ `
+
+ /**
+ * @private
+ * @type {EditForModalRecordView}
+ */
+ recordView
+
+ /**
+ * @private
+ * @type {Model}
+ */
+ formModel
+
+ /**
+ * @param {{
+ * onApply: function({soft: boolean, small: boolean}),
+ * label: string,
+ * data: {
+ * soft: boolean,
+ * small: boolean,
+ * },
+ * }} options
+ */
+ constructor(options) {
+ super();
+
+ this.options = options;
+ }
+
+ setup() {
+ this.headerText = this.translate('Edit') + ' · ' + this.options.label;
+
+ this.formModel = new Model();
+ this.formModel.setMultiple({...this.options.data});
+
+ this.recordView = new EditForModalRecordView({
+ model: this.formModel,
+ detailLayout: [
+ {
+ rows: [
+ [
+ {
+ view: new BoolFieldView({
+ name: 'soft',
+ labelText: this.translate('soft', 'otherFields', 'DashletOptions'),
+ }),
+ },
+ {
+ view: new BoolFieldView({
+ name: 'small',
+ labelText: this.translate('small', 'otherFields', 'DashletOptions'),
+ }),
+ },
+ ],
+ ]
+ }
+ ]
+ });
+
+ this.assignView('record', this.recordView);
+
+ this.buttonList = [
+ {
+ name: 'apply',
+ style: 'danger',
+ label: 'Apply',
+ onClick: () => this.actionApply(),
+ },
+ {
+ name: 'cancel',
+ label: 'Cancel',
+ onClick: () => this.actionClose(),
+ },
+ ]
+ }
+
+ /**
+ * @private
+ */
+ actionApply() {
+ if (this.recordView.validate()) {
+ return;
+ }
+
+ this.options.onApply({
+ soft: this.formModel.attributes.soft,
+ small: this.formModel.attributes.small,
+ });
+
+ this.close();
+ }
+}
diff --git a/frontend/less/espo/misc/selectize/custom.less b/frontend/less/espo/misc/selectize/custom.less
index f2d2d2defd..13cf0d8d1a 100644
--- a/frontend/less/espo/misc/selectize/custom.less
+++ b/frontend/less/espo/misc/selectize/custom.less
@@ -105,6 +105,12 @@
color: var(--state-success-text);
}
}
+
+ &.soft {
+ > span {
+ color: var(--gray-soft);
+ }
+ }
}
}
}