diff --git a/src/components/FormSelectList.vue b/src/components/FormSelectList.vue index 678dd873..b0f0c5d9 100644 --- a/src/components/FormSelectList.vue +++ b/src/components/FormSelectList.vue @@ -528,33 +528,28 @@ export default { list.forEach((item) => { // if the content has a mustache expression - const { escape } = Mustache; - Mustache.escape = (t) => t; // Do not escape mustache content - let parsedOption = {}; if (this.options.key) { const itemValue = this.options.key.indexOf("{{") >= 0 - ? Mustache.render(this.options.key, item) - : Mustache.render(`{{${this.options.key || "value"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.key), item) + : Mustache.render(`{{{${this.options.key || "value"}}}}`, item); parsedOption[this.optionsKey] = itemValue; } const itemContent = this.options.value.indexOf("{{") >= 0 - ? Mustache.render(this.options.value, item) - : Mustache.render(`{{${this.options.value || "content"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.value), item) + : Mustache.render(`{{{${this.options.value || "content"}}}}`, item); // Modified ariaLabel handling let itemAriaLabel = itemContent; if (this.options.optionAriaLabel) { itemAriaLabel = this.options.optionAriaLabel.indexOf("{{") >= 0 - ? Mustache.render(this.options.optionAriaLabel, item) - : Mustache.render(`{{${this.options.optionAriaLabel || "ariaLabel"}}}`, item); + ? Mustache.render(this.toUnescapedMustache(this.options.optionAriaLabel), item) + : Mustache.render(`{{{${this.options.optionAriaLabel || "ariaLabel"}}}}`, item); } - Mustache.escape = escape; // Reset mustache to original escape function - parsedOption[this.optionsValue] = itemContent; parsedOption[this.optionsAriaLabel] = itemAriaLabel; @@ -579,6 +574,17 @@ export default { }); return resultList; }, + toUnescapedMustache(template) { + // Convert {{ var }} to {{{ var }}} so special characters are not HTML-escaped. + // Leave sections, inverted sections, partials, comments, and already-unescaped tags alone. + return template.replace(/\{\{(?!\{)([^}]+)\}\}(?!\})/g, (match, content) => { + const trimmed = content.trim(); + if (!trimmed || /^[#/^!>&]/.test(trimmed)) { + return match; + } + return `{{{${content}}}}`; + }); + }, addObjectContentProp(parsedOption) { if (!(parsedOption instanceof Object)) { return parsedOption; @@ -588,10 +594,14 @@ export default { let ariaLabelProperty = this.options.ariaLabel || this.options.value; if (contentProperty.indexOf("{{") === -1) { - contentProperty = `{{ ${contentProperty} }}`; + contentProperty = `{{{ ${contentProperty} }}}`; + } else { + contentProperty = this.toUnescapedMustache(contentProperty); } if (ariaLabelProperty.indexOf("{{") === -1) { - ariaLabelProperty = `{{ ${ariaLabelProperty} }}`; + ariaLabelProperty = `{{{ ${ariaLabelProperty} }}}`; + } else { + ariaLabelProperty = this.toUnescapedMustache(ariaLabelProperty); } if (!parsedOption.hasOwnProperty(this.optionsValue)) { diff --git a/tests/unit/FormSelectList.spec.js b/tests/unit/FormSelectList.spec.js index 46950209..0f27eb68 100644 --- a/tests/unit/FormSelectList.spec.js +++ b/tests/unit/FormSelectList.spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils' +import Mustache from 'mustache'; import FormSelectList from '../../src/components/FormSelectList.vue'; describe('FormSelectList', () => { @@ -244,4 +245,93 @@ describe('FormSelectList', () => { expect(wrapper.find('.invalid-feedback').exists()).toBe(false); expect(wrapper.find('select').classes('is-invalid')).toBe(false); }); + + describe('Mustache label escaping for object values', () => { + const specialLabel = 'Hello & World '; + const dataConnectorOptions = { + renderAs: 'dropdown', + allowMultiSelect: true, + dataSource: 'dataConnector', + valueTypeReturned: 'object', + key: 'id', + value: 'name', + optionAriaLabel: 'name' + }; + + beforeEach(() => { + window.ProcessMaker = { user: {} }; + window.validatorLanguageSet = true; + }); + + const mountDataConnector = () => + shallowMount(FormSelectList, { + mocks: { $t }, + propsData: { + options: dataConnectorOptions + }, + computed: { + mode: () => 'preview' + } + }); + + it('does not HTML-escape special characters in transformOptions', () => { + const wrapper = mountDataConnector(); + const list = [{ id: 1, name: specialLabel }]; + + const transformed = wrapper.vm.transformOptions(list); + + expect(transformed[0].__content__).toBe(specialLabel); + expect(transformed[0].__ariaLabel__).toBe(specialLabel); + expect(transformed[0].__content__).not.toContain('&'); + expect(transformed[0].__content__).not.toContain('<'); + }); + + it('does not HTML-escape special characters in addObjectContentProp (reload path)', () => { + const wrapper = mountDataConnector(); + const selectedObject = { id: 1, name: specialLabel }; + + const withContent = wrapper.vm.addObjectContentProp(selectedObject); + + expect(withContent.__content__).toBe(specialLabel); + expect(withContent.__ariaLabel__).toBe(specialLabel); + expect(withContent.__content__).not.toContain('&'); + expect(withContent.__content__).not.toContain('<'); + }); + + it('converts double-brace variables to triple-brace without touching Mustache.escape', () => { + const wrapper = mountDataConnector(); + const originalEscape = Mustache.escape; + + expect(wrapper.vm.toUnescapedMustache('{{name}}')).toBe('{{{name}}}'); + expect(wrapper.vm.toUnescapedMustache('{{name}} - {{code}}')).toBe('{{{name}}} - {{{code}}}'); + expect(wrapper.vm.toUnescapedMustache('{{#items}}{{name}}{{/items}}')).toBe('{{#items}}{{{name}}}{{/items}}'); + expect(wrapper.vm.toUnescapedMustache('{{{name}}}')).toBe('{{{name}}}'); + + const rendered = Mustache.render(wrapper.vm.toUnescapedMustache('{{name}}'), { name: specialLabel }); + expect(rendered).toBe(specialLabel); + expect(Mustache.escape).toBe(originalEscape); + expect(Mustache.escape('&')).toBe('&'); + }); + + it('does not HTML-escape special characters for mustache templates on reload', () => { + const wrapper = shallowMount(FormSelectList, { + mocks: { $t }, + propsData: { + options: { + ...dataConnectorOptions, + value: '{{name}}' + } + }, + computed: { + mode: () => 'preview' + } + }); + const selectedObject = { id: 1, name: specialLabel }; + + const withContent = wrapper.vm.addObjectContentProp(selectedObject); + + expect(withContent.__content__).toBe(specialLabel); + expect(withContent.__ariaLabel__).toBe(specialLabel); + }); + }); });