Files
espocrm/client/src/view-helper.js
2026-06-11 06:28:18 +03:00

979 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/************************************************************************
* 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 view-helper */
import {marked, Lexer} from 'marked';
import DOMPurify from 'dompurify';
import Handlebars from 'handlebars';
/**
* A view helper.
*/
class ViewHelper {
constructor() {
this._registerHandlebarsHelpers();
/** @private */
this.mdBeforeList = [
/*{
regex: /```\n?([\s\S]*?)```/g,
value: (s, string) => {
return '```\n' + string.replace(/\\\>/g, '>') + '```';
},
},*/
{
// Also covers triple-backtick blocks.
regex: /`([\s\S]*?)`/g,
value: (s, string) => {
// noinspection RegExpRedundantEscape
return '`' + string.replace(/\\\</g, '<') + '`';
},
},
];
marked.setOptions({
breaks: true,
tables: false,
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')) {
node.targetBlank = true;
}
else {
node.targetBlank = false;
}
}
if (node instanceof HTMLOListElement && node.start && node.start > 99) {
node.removeAttribute('start');
}
if (node instanceof HTMLFormElement) {
if (node.action) {
node.removeAttribute('action');
}
if (node.hasAttribute('method')) {
node.removeAttribute('method');
}
}
if (node instanceof HTMLButtonElement) {
if (node.type === 'submit') {
node.type = 'button';
}
}
});
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if (node instanceof HTMLAnchorElement) {
const href = node.getAttribute('href');
if (href && !href.startsWith('#')) {
node.setAttribute('rel', 'noopener noreferrer');
}
if (node.targetBlank) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
}
});
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (data.attrName === 'style') {
const style = data.attrValue
.split(';')
.map(s => s.trim())
.filter(rule => {
const [property, value] = rule.split(':')
.map(s => s.trim().toLowerCase());
if (
property === 'position' &&
['absolute', 'fixed', 'sticky'].includes(value)
) {
return false;
}
return true;
});
data.attrValue = style.join('; ');
}
});
}
/**
* A layout manager.
*
* @type {module:layout-manager}
*/
layoutManager = null
/**
* A config.
*
* @type {module:models/settings}
*/
settings = null
/**
* A config.
*
* @type {module:models/settings}
*/
config = null
/**
* A current user.
*
* @type {module:models/user}
*/
user = null
/**
* A preferences.
*
* @type {module:models/preferences}
*/
preferences = null
/**
* An ACL manager.
*
* @type {module:acl-manager}
*/
acl = null
/**
* A model factory.
*
* @type {module:model-factory}
*/
modelFactory = null
/**
* A collection factory.
*
* @type {module:collection-factory}
*/
collectionFactory = null
/**
* A router.
*
* @type {import('router').default}
*/
router = null
/**
* A storage.
*
* @type {import('storage').default}
*/
storage = null
/**
* A session storage.
*
* @type {module:session-storage}
*/
sessionStorage = null
/**
* A date-time util.
*
* @type {module:date-time}
*/
dateTime = null
/**
* A language.
*
* @type {module:language}
*/
language = null
/**
* A metadata.
*
* @type {module:metadata}
*/
metadata = null
/**
* A field-manager util.
*
* @type {module:field-manager}
*/
fieldManager = null
/**
* A cache.
*
* @type {module:cache}
*/
cache = null
/**
* A theme manager.
*
* @type {module:theme-manager}
*/
themeManager = null
/**
* A web-socket manager. Null if not enabled.
*
* @type {module:web-socket-manager|null}
*/
webSocketManager = null
/**
* A number util.
*
* @type {module:num-util}
*/
numberUtil = null
/**
* A page-title util.
*
* @type {module:page-title}
*/
pageTitle = null
/**
* A broadcast channel.
*
* @type {?module:broadcast-channel}
*/
broadcastChannel = null
/**
* A base path.
*
* @type {string}
*/
basePath = ''
/**
* Application parameters.
*
* @type {import('app-params').default|null}
*/
appParams = null
/**
* @private
*/
_registerHandlebarsHelpers() {
Handlebars.registerHelper('img', img => {
return new Handlebars.SafeString(`<img src="img/${img}" alt="img">`);
});
Handlebars.registerHelper('prop', (object, name) => {
if (object === undefined) {
console.warn(`Undefined value passed to 'prop' helper.`);
return undefined;
}
if (name in object) {
return object[name];
}
return undefined;
});
Handlebars.registerHelper('var', (name, context, options) => {
if (typeof context === 'undefined') {
return null;
}
let contents = context[name];
if (options.hash.trim) {
contents = contents.trim();
}
return new Handlebars.SafeString(contents);
});
Handlebars.registerHelper('concat', function (left, right) {
return left + right;
});
Handlebars.registerHelper('ifEqual', function (left, right, options) {
// noinspection EqualityComparisonWithCoercionJS
if (left == right) {
return options.fn(this);
}
return options.inverse(this);
});
Handlebars.registerHelper('ifNotEqual', function (left, right, options) {
// noinspection EqualityComparisonWithCoercionJS
if (left != right) {
return options.fn(this);
}
return options.inverse(this);
});
Handlebars.registerHelper('ifPropEquals', function (object, property, value, options) {
// noinspection EqualityComparisonWithCoercionJS
if (object[property] == value) {
return options.fn(this);
}
return options.inverse(this);
});
Handlebars.registerHelper('ifAttrEquals', function (model, attr, value, options) {
// noinspection EqualityComparisonWithCoercionJS
if (model.get(attr) == value) {
return options.fn(this);
}
return options.inverse(this);
});
Handlebars.registerHelper('ifAttrNotEmpty', function (model, attr, options) {
const value = model.get(attr);
if (value !== null && typeof value !== 'undefined') {
return options.fn(this);
}
return options.inverse(this);
});
Handlebars.registerHelper('get', (model, name) => model.get(name));
Handlebars.registerHelper('length', arr => arr.length);
Handlebars.registerHelper('translate', (name, options) => {
const scope = options.hash.scope || null;
const category = options.hash.category || null;
if (name === 'null') {
return '';
}
return this.language.translate(name, category, scope);
});
Handlebars.registerHelper('dropdownItem', (name, options) => {
const scope = options.hash.scope || null;
const label = options.hash.label;
const labelTranslation = options.hash.labelTranslation;
const data = options.hash.data;
const hidden = options.hash.hidden;
const disabled = options.hash.disabled;
const title = options.hash.title;
const link = options.hash.link;
const action = options.hash.action || name;
const iconHtml = options.hash.iconHtml;
const iconClass = options.hash.iconClass;
let html =
options.hash.html ||
options.hash.text ||
(
labelTranslation ?
this.language.translatePath(labelTranslation) :
this.language.translate(label, 'labels', scope)
);
if (!options.hash.html) {
html = this.escapeString(html);
}
if (iconHtml) {
html = iconHtml + ' ' + html;
}
else if (iconClass) {
const iconHtml = $('<span>').addClass(iconClass).get(0).outerHTML;
html = iconHtml + ' ' + html;
}
const $li = $('<li>')
.addClass(hidden ? 'hidden' : '')
.addClass(disabled ? 'disabled' : '');
const $a = $('<a>')
.attr('role', 'button')
.attr('tabindex', '0')
.attr('data-name', name)
.addClass(options.hash.className || '')
.addClass('action')
.html(html);
if (action) {
$a.attr('data-action', action);
}
$li.append($a);
link ?
$a.attr('href', link) :
$a.attr('role', 'button');
if (data) {
for (const key in data) {
$a.attr('data-' + Espo.Utils.camelCaseToHyphen(key), data[key]);
}
}
if (disabled) {
$li.attr('disabled', 'disabled');
}
if (title) {
$a.attr('title', title);
}
return new Handlebars.SafeString($li.get(0).outerHTML);
});
Handlebars.registerHelper('button', (name, options) => {
const style = options.hash.style || 'default';
const scope = options.hash.scope || null;
const label = options.hash.label || name;
const labelTranslation = options.hash.labelTranslation;
const link = options.hash.link;
const iconHtml = options.hash.iconHtml;
const iconClass = options.hash.iconClass;
let html =
options.hash.html ||
options.hash.text ||
(
labelTranslation ?
this.language.translatePath(labelTranslation) :
this.language.translate(label, 'labels', scope)
);
if (!options.hash.html) {
html = this.escapeString(html);
}
if (iconHtml) {
html = iconHtml + ' ' + '<span>' + html + '</span>';
}
else if (iconClass) {
const iconHtml = $('<span>').addClass(iconClass).get(0).outerHTML;
html = iconHtml + ' ' + '<span>' + html + '</span>';
}
const tag = link ? '<a>' : '<button>';
const $button = $(tag)
.addClass('btn action')
.addClass(options.hash.className || '')
.addClass(options.hash.hidden ? 'hidden' : '')
.addClass(options.hash.disabled ? 'disabled' : '')
.attr('data-action', name)
.attr('data-name', name)
.addClass('btn-' + style)
.html(html);
link ?
$button.href(link) :
$button.attr('type', 'button')
if (options.hash.disabled) {
$button.attr('disabled', 'disabled');
}
if (options.hash.title) {
$button.attr('title', options.hash.title);
}
return new Handlebars.SafeString($button.get(0).outerHTML);
});
Handlebars.registerHelper('hyphen', (string) => {
return Espo.Utils.convert(string, 'c-h');
});
Handlebars.registerHelper('toDom', (string) => {
return Espo.Utils.toDom(string);
});
// noinspection SpellCheckingInspection
Handlebars.registerHelper('breaklines', (text) => {
text = Handlebars.Utils.escapeExpression(text || '');
text = text.replace(/(\r\n|\n|\r)/gm, '<br>');
return new Handlebars.SafeString(text);
});
Handlebars.registerHelper('complexText', (text, options) => {
if (typeof text !== 'string' && !(text instanceof String)) {
return '';
}
return this.transformMarkdownText(text, options.hash);
});
Handlebars.registerHelper('translateOption', (name, options) => {
const scope = options.hash.scope || null;
const field = options.hash.field || null;
if (!field) {
return '';
}
let translationHash = options.hash.translatedOptions || null;
if (translationHash === null) {
translationHash = this.language.translate(/** @type {string} */field, 'options', scope) || {};
if (typeof translationHash !== 'object') {
translationHash = {};
}
}
if (name === null) {
name = '';
}
return translationHash[name] || name;
});
Handlebars.registerHelper('options', (/** any[] */list, value, options) => {
if (typeof value === 'undefined') {
value = false;
}
list = list || [];
let html = '';
const multiple = (Object.prototype.toString.call(value) === '[object Array]');
const checkOption = name => {
if (multiple) {
return value.indexOf(name) !== -1;
}
return value === name || (!value && !name && name !== 0);
};
options.hash = /** @type {Record} */options.hash || {};
const scope = options.hash.scope || false;
const category = options.hash.category || false;
const field = options.hash.field || false;
const styleMap = options.hash.styleMap || {};
if (!multiple && options.hash.includeMissingOption && (value || value === '')) {
if (!list.includes(value)) {
list = Espo.Utils.clone(list);
list.push(value);
}
}
let translationHash = options.hash.translationHash ||
options.hash.translatedOptions ||
null;
if (translationHash === null) {
translationHash = {};
if (!category && field) {
translationHash = this.language
.translate(/** @type {string} */field, 'options', /** @type {string} */scope) || {};
if (typeof translationHash !== 'object') {
translationHash = {};
}
}
}
const translate = name => {
if (!category) {
return translationHash[name] || name;
}
return this.language.translate(name, category, /** @type {string} */scope);
};
for (const key in list) {
const value = list[key];
const label = translate(value);
const $option =
$('<option>')
.attr('value', value)
.addClass(styleMap[value] ? 'text-' + styleMap[value] : '')
.text(label);
if (checkOption(list[key])) {
$option.attr('selected', 'selected')
}
html += $option.get(0).outerHTML;
}
return new Handlebars.SafeString(html);
});
Handlebars.registerHelper('basePath', () => {
return this.basePath || '';
});
}
/**
* Get an application parameter.
*
* @param {string} name
* @returns {*}
*/
getAppParam(name) {
if (!this.appParams) {
return undefined;
}
return this.appParams.get(name);
}
/**
* Escape a string.
*
* @param {string} text A string.
* @returns {string}
*/
escapeString(text) {
return Handlebars.Utils.escapeExpression(text);
}
/**
* Get a user avatar HTML.
*
* @param {string} id A user ID.
* @param {'small'|'medium'|'large'} [size='small'] A size.
* @param {int} [width=16]
* @param {string} [additionalClassName] An additional class-name.
* @returns {string}
*/
getAvatarHtml(id, size, width, additionalClassName) {
if (this.config.get('avatarsDisabled')) {
return '';
}
const t = this.cache ? this.cache.get('app', 'timestamp') : this.settings.get('cacheTimestamp');
const basePath = this.basePath || '';
size = size || 'small';
width = width || 16;
let className = 'avatar';
if (additionalClassName) {
className += ' ' + additionalClassName;
}
// noinspection RequiredAttributes,HtmlRequiredAltAttribute
return $(`<img>`)
.attr('src', `${basePath}?entryPoint=avatar&size=${size}&id=${id}&t=${t}`)
.attr('alt', 'avatar')
.addClass(className)
.attr('data-width', width.toString())
.css('width', `var(--${width.toString()}px)`)
.attr('draggable', 'false')
.get(0).outerHTML;
}
/**
* A Markdown text to HTML (one-line).
*
* @param {string} text A text.
* @returns {Handlebars.SafeString} HTML.
*/
transformMarkdownInlineText(text) {
return this.transformMarkdownText(text, {inline: true});
}
/**
* A Markdown text to HTML.
*
* @param {string} text A text.
* @param {{inline?: boolean, linksInNewTab?: boolean}} [options] Options.
* @returns {Handlebars.SafeString} HTML.
*/
transformMarkdownText(text, options) {
text = text || '';
this.mdBeforeList.forEach(item => {
text = text.replace(item.regex, item.value);
});
options = options || {};
text = options.inline ?
marked.parseInline(text) :
marked.parse(text);
text = DOMPurify.sanitize(text, {}).toString();
if (options.linksInNewTab) {
text = text.replace(/<a href=/gm, '<a target="_blank" rel="noopener noreferrer" href=');
}
text = text.replace(
/<a href="mailto:([^"]*)"/gm,
'<a role="button" class="selectable" data-email-address="$1" data-action="mailTo"'
);
return new Handlebars.SafeString(text);
}
/**
* Get a color-icon HTML for a scope.
*
* @param {string} scope A scope.
* @param {boolean} [noWhiteSpace=false] No white space.
* @param {string} [additionalClassName] An additional class-name.
* @returns {string}
*/
getScopeColorIconHtml(scope, noWhiteSpace, additionalClassName) {
if (this.config.get('scopeColorsDisabled') || this.preferences.get('scopeColorsDisabled')) {
return '';
}
const color = this.metadata.get(['clientDefs', scope, 'color']);
let html = '';
if (color) {
const $span = $('<span class="color-icon fas fa-square">');
$span.css('color', color);
if (additionalClassName) {
$span.addClass(additionalClassName);
}
html = $span.get(0).outerHTML;
}
if (!noWhiteSpace && html) {
html += `<span style="user-select: none;">&nbsp;</span>`;
}
return html;
}
/**
* Sanitize HTML.
*
* @param {string} text An HTML.
* @returns {string}
*/
sanitizeHtml(text) {
return DOMPurify.sanitize(text).toString();
}
/**
* Moderately sanitize HTML.
*
* @param {string} value An HTML.
* @returns {string}
*/
moderateSanitizeHtml(value) {
const params = {
ADD_ATTR: ['x-if', 'iterate'],
ADD_TAGS: ['#comment'],
};
return DOMPurify.sanitize(value || '', params).toString();
}
/**
* Calculate a content container height.
*
* @param {HTMLElement|JQuery} element An element.
* @returns {number}
*/
calculateContentContainerHeight(element) {
const smallScreenWidth = this.themeManager.getParam('screenWidthXs');
const $window = $(window);
const footerHeight = $('#footer').height() || 26;
let top = 0;
element = $(element).get(0);
if (element) {
top = element.getBoundingClientRect().top;
if ($window.width() < smallScreenWidth) {
const $navbarCollapse = $('#navbar .navbar-body');
if ($navbarCollapse.hasClass('in') || $navbarCollapse.hasClass('collapsing')) {
top -= $navbarCollapse.height();
}
}
}
const spaceHeight = top + footerHeight;
return $window.height() - spaceHeight - 20;
}
/**
* Process view-setup-handlers.
*
* @param {import('view').default<any>} view A view.
* @param {string} type A view-setup-handler type.
* @param {string} [scope] A scope.
* @return Promise
*/
processSetupHandlers(view, type, scope) {
// noinspection JSUnresolvedReference
scope = scope || view.scope || view.entityType;
let handlerIdList = this.metadata.get(['clientDefs', 'Global', 'viewSetupHandlers', type]) || [];
if (scope) {
handlerIdList = handlerIdList
.concat(
this.metadata.get(['clientDefs', scope, 'viewSetupHandlers', type]) || []
);
}
if (handlerIdList.length === 0) {
return Promise.resolve();
}
/**
* @interface
* @name ViewHelper~Handler
*/
/**
* @function
* @name ViewHelper~Handler#process
* @param {import('view').default} [view] Deprecated.
*/
const promiseList = [];
for (const id of handlerIdList) {
const promise = new Promise(resolve => {
Espo.loader.require(id, /** typeof ViewHelper~Handler */Handler => {
const result = (new Handler(view)).process(view);
if (result && Object.prototype.toString.call(result) === '[object Promise]') {
result.then(() => resolve());
return;
}
resolve();
});
});
promiseList.push(promise);
}
return Promise.all(promiseList);
}
/** @private */
_isXsScreen
/**
* Is xs screen width.
*
* @return {boolean}
*/
isXsScreen() {
if (this._isXsScreen == null) {
this._isXsScreen = window.innerWidth < this.themeManager.getParam('screenWidthXs');
}
return this._isXsScreen;
}
}
export default ViewHelper;