Skip to content

Commit

Permalink
Merge pull request #342 from moreonion/csp-vue
Browse files Browse the repository at this point in the history
fix: Avoid v-html for highlighted text
  • Loading branch information
torotil authored Jan 20, 2025
2 parents 5192849 + 2913658 commit a74117a
Show file tree
Hide file tree
Showing 12 changed files with 802 additions and 118 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,23 @@ You can use this component with `v-model` to get/set its value.
<ul v-if="showDropdown" ref="dropdown" class="dropdown-menu">
<li v-for="(item, index) in items" :class="{'active': isActive(index)}">
<a class="dropdown-item" @mousedown.stop.prevent="hit" @mousemove="setActive(index)">
<component :is="templateComp" :item="item" :value="val"></component>
<HighlightedText :text="item[labelKey]" :search="val"></HighlightedText>
</a>
</li>
</ul>
</div>
</template>

<script>
import {fixedEncodeURIComponent, escapeRegExp} from '../utils'
import HighlightedText from './HighlightedText.vue';
import {fixedEncodeURIComponent} from '../utils'
const _DELAY_ = 200
export default {
components: {
HighlightedText,
},
props: {
value: { /** The component’s value. */
type: Object,
Expand All @@ -58,7 +62,6 @@ export default {
type: Number,
default: 8
},
template: String, /** Used to render a suggestion. */
dataKey: { /** The key of the suggestions array in the response JSON. If not set, the response itself is expected to by an array of suggestions. */
type: String,
default: null
Expand Down Expand Up @@ -114,26 +117,6 @@ export default {
},
computed: {
/**
* A vue component that uses the `template` prop and offers a `highlight` method
* for the template to use.
* @return {Object} The templateComp component.
*/
templateComp () {
return {
template: typeof this.template === 'string' ? '<span v-html="this.template"></span>' : '<span v-html="highlight(item.' + this.labelKey + ', value)"></span>',
props: {
item: {default: null},
value: String
},
methods: {
highlight (string, phrase) {
return (string && phrase && string.replace(new RegExp('(' + escapeRegExp(phrase) + ')', 'gi'), '<strong>$1</strong>')) || string
}
}
}
},
/**
* Guess whether the user entered a url or a path.
* @return {boolean} `true` if the user probably entered a url or a path.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<docs>
HighlightedText component.
Puts matching parts of a text in <strong> tags to highlight them.
</docs>

<template>
<span>
<template v-for="part in getParts(text, search)">
<strong v-if="part.highlight">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
</template>

<script>
import { escapeRegExp } from '../utils'
export default {
props: {
text: { default: null },
search: String
},
methods: {
getParts (text, search) {
if (!text || !search) {
return [{text, highlight: false}]
}
const escapedSearch = escapeRegExp(search)
const matchRegexp = new RegExp('^' + escapedSearch + '$', 'i')
return text.split(new RegExp('(?=' + escapedSearch + ')|(?<=' + escapedSearch + ')', 'gi')).map((part) => ({
text: part,
highlight: matchRegexp.test(part)
}))
},
},
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ Per default, you can use this component with `v-model` to get/set its value.
<ul v-if="showDropdown" @scroll="scroll" ref="dropdown" class="dropdown-menu">
<li v-for="(item, index) in items" :class="{'active': isActive(index)}">
<a class="dropdown-item" @mousedown.prevent="hit" @mousemove="setActive(index)">
<component :is="templateComp" :item="item" :value="val"></component>
<HighlightedText :text="item" :search="val"></HighlightedText>
</a>
</li>
</ul>
</div>
</template>

<script>
import HighlightedText from './HighlightedText.vue';
const _DELAY_ = 200
/**
Expand Down Expand Up @@ -66,6 +68,10 @@ function fixedEncodeURIComponent (str) {
export default {
components: {
HighlightedText
},
created () {
this.items = this.primitiveData
},
Expand All @@ -85,7 +91,6 @@ export default {
type: Object,
default: {}
},
template: String, /** Used to render suggestion. */
dataKey: { /** The key of the suggestions array in the response JSON. If not set, the response itself is expected to by an array of suggestions. */
type: String,
default: null
Expand Down Expand Up @@ -156,26 +161,6 @@ export default {
},
computed: {
/**
* A vue component that uses the `template` prop and offers a `highlight` method
* for the template to use.
* @return {Object} The templateComp component.
*/
templateComp () {
return {
template: typeof this.template === 'string' ? '<span v-html="this.template"></span>' : '<span v-html="highlight(item, value)"></span>',
props: {
item: {default: null},
value: String
},
methods: {
highlight (string, phrase) {
return (string && phrase && string.replace(new RegExp('(' + phrase + ')', 'gi'), '<strong>$1</strong>')) || string
}
}
}
},
/**
* Client-side mode: return a filtered array of items.
* HTTP mode: return an empty array.
Expand Down
18 changes: 9 additions & 9 deletions campaignion_wizard/js/redirects_app/redirects_app.vue.min.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions campaignion_wizard/redirects_app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"vuex": "2.3.1"
},
"devDependencies": {
"@testing-library/vue": "5",
"@vitejs/plugin-vue2": "1.1.2",
"chromedriver": "119.0.1",
"concurrently": "7.6.0",
Expand Down
2 changes: 1 addition & 1 deletion campaignion_wizard/redirects_app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default {
components: {
RedirectList,
RedirectDialog,
DestinationField
DestinationField,
},
data () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ You can use this component with `v-model` to get/set its value.
<ul v-if="showDropdown" ref="dropdown" class="dropdown-menu">
<li v-for="(item, index) in items" :class="{'active': isActive(index)}">
<a class="dropdown-item" @mousedown.stop.prevent="hit" @mousemove="setActive(index)">
<component :is="templateComp" :item="item" :value="val"></component>
<HighlightedText :text="item[labelKey]" :search="val"></HighlightedText>
</a>
</li>
</ul>
Expand All @@ -38,10 +38,14 @@ You can use this component with `v-model` to get/set its value.

<script>
import { fixedEncodeURIComponent, escapeRegExp } from '../utils'
import HighlightedText from './HighlightedText.vue';
const _DELAY_ = 200
export default {
components: {
HighlightedText,
},
props: {
value: { /** The component’s value. */
type: Object,
Expand All @@ -58,7 +62,6 @@ export default {
type: Number,
default: 8
},
template: String, /** Used to render a suggestion. */
dataKey: { /** The key of the suggestions array in the response JSON. If not set, the response itself is expected to by an array of suggestions. */
type: String,
default: null
Expand Down Expand Up @@ -114,26 +117,6 @@ export default {
},
computed: {
/**
* A vue component that uses the `template` prop and offers a `highlight` method
* for the template to use.
* @return {Object} The templateComp component.
*/
templateComp () {
return {
template: typeof this.template === 'string' ? '<span v-html="this.template"></span>' : '<span v-html="highlight(item.' + this.labelKey + ', value)"></span>',
props: {
item: { default: null },
value: String
},
methods: {
highlight (string, phrase) {
return (string && phrase && string.replace(new RegExp('(' + escapeRegExp(phrase) + ')', 'gi'), '<strong>$1</strong>')) || string
}
}
}
},
/**
* Guess whether the user entered a url or a path.
* @return {boolean} `true` if the user probably entered a url or a path.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<docs>
HighlightedText component.
Puts matching parts of a text in <strong> tags to highlight them.
</docs>

<template>
<span>
<template v-for="part in getParts(text, search)">
<strong v-if="part.highlight">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
</template>

<script>
import { escapeRegExp } from '../utils'
export default {
props: {
text: { default: null },
search: String
},
methods: {
getParts (text, search) {
if (!text || !search) {
return [{text, highlight: false}]
}
const escapedSearch = escapeRegExp(search)
const matchRegexp = new RegExp('^' + escapedSearch + '$', 'i')
return text.split(new RegExp('(?=' + escapedSearch + ')|(?<=' + escapedSearch + ')', 'gi')).map((part) => ({
text: part,
highlight: matchRegexp.test(part)
}))
},
},
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -46,30 +46,6 @@ describe('DestinationField.vue', function () {
})

describe('computed', function () {
describe('templateComp', function () {
const context = {
labelKey: 'foo'
}
it('returns a vue component.', function () {
const templateComp = c.computed.templateComp.call(context)
assert.equal(templateComp.template, '<span v-html="highlight(item.foo, value)"></span>')
assert.deepEqual(templateComp.props, {
item: { default: null },
value: String
})
assert.equal(typeof templateComp.methods.highlight, 'function')
})
describe('methods.highlight', function () {
const highlight = c.computed.templateComp.call(context).methods.highlight
it('encloses the phrase in <strong> tags.', function () {
assert.equal(highlight('Hello world!', 'or'), 'Hello w<strong>or</strong>ld!')
})
it('returns the string if the phrase was not found.', function () {
assert.equal(highlight('Hello world!', 'foo'), 'Hello world!')
})
})
})

describe('urlMode', function () {
const urlMode = c.computed.urlMode
it('returns true if this.val starts with ww', function () {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import sinon from 'sinon'
import { render } from '@testing-library/vue'

import HighlightedText from '../../../src/components/HighlightedText.vue'

afterEach(() => {
sinon.restore()
})

describe('HighlightedText', function () {
describe('template', function () {
it('renders an empty span with empty input.', function () {
const wrapper = render(HighlightedText)
assert.equal(wrapper.html(), '<span></span>')
})
it('renders the text unchanged without a search term.', function () {
const wrapper = render(HighlightedText, { propsData: { text: 'foo' } })
assert.equal(wrapper.html(), '<span>foo</span>')
})
it('highlights the search term.', function () {
const wrapper = render(HighlightedText, {
propsData: {
text: 'Hello world!',
search: 'o'
}
})
assert.equal(wrapper.html(), '<span>Hell<strong>o</strong> w<strong>o</strong>rld!</span>')
})
})
})
Loading

0 comments on commit a74117a

Please sign in to comment.