Commit cc865de0 authored by JC Brand's avatar JC Brand

Add new config setting `autocomplete_add_contact`

Determines whether search suggestions are shown in the "Add Contact" modal.
parent 42128e05
# Changelog # Changelog
## 4.1.3 (Unreleased) ## 4.2.0 (Unreleased)
- Updated translation: lt - Updated translation: lt
- Upgrade to Backbone 1.4.0, Strophe 1.3.2 and Jasmine 2.99.2 - Upgrade to Backbone 1.4.0, Strophe 1.3.2 and Jasmine 2.99.2
...@@ -9,13 +9,14 @@ ...@@ -9,13 +9,14 @@
- Fix handling of CAPTCHAs offered by ejabberd - Fix handling of CAPTCHAs offered by ejabberd
- Don't send out receipts or markers for MAM messages - Don't send out receipts or markers for MAM messages
- Allow setting of debug mode via URL with `/#converse?debug=true` - Allow setting of debug mode via URL with `/#converse?debug=true`
- Render inline images served over HTTP if Converse itself was loaded on an unsecured (HTTP) page.
- Make sure `nickname` passed in via `_converse.initialize` has first preference as MUC nickname
- Make sure required registration fields have "required" attribute
- New config setting [autocomplete_add_contact](https://conversejs.org/docs/html/configuration.html#autocomplete-add-contact)
- New config setting [locked_muc_domain](https://conversejs.org/docs/html/configuration.html#locked-muc-domain) - New config setting [locked_muc_domain](https://conversejs.org/docs/html/configuration.html#locked-muc-domain)
- New config setting [locked_muc_nickname](https://conversejs.org/docs/html/configuration.html#locked-muc-nickname) - New config setting [locked_muc_nickname](https://conversejs.org/docs/html/configuration.html#locked-muc-nickname)
- New config setting [show_client_info](https://conversejs.org/docs/html/configuration.html#show-client-info) - New config setting [show_client_info](https://conversejs.org/docs/html/configuration.html#show-client-info)
- Render inline images served over HTTP if Converse itself was loaded on an unsecured (HTTP) page.
- Document new API method [sendMessage](https://conversejs.org/docs/html/api/-_converse.ChatBox.html#sendMessage) - Document new API method [sendMessage](https://conversejs.org/docs/html/api/-_converse.ChatBox.html#sendMessage)
- Make sure `nickname` passed in via `_converse.initialize` has first preference as MUC nickname
- Make sure required registration fields have "required" attribute
- #1149: With `xhr_user_search_url`, contact requests are not being sent out - #1149: With `xhr_user_search_url`, contact requests are not being sent out
- #1213: Switch roster filter input and icons - #1213: Switch roster filter input and icons
- #1327: fix False mentions positives in URLs and Email addresses - #1327: fix False mentions positives in URLs and Email addresses
......
...@@ -58987,6 +58987,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins ...@@ -58987,6 +58987,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins
__ = _converse.__; __ = _converse.__;
_converse.api.settings.update({ _converse.api.settings.update({
'autocomplete_add_contact': true,
'allow_chat_pending_contacts': true, 'allow_chat_pending_contacts': true,
'allow_contact_removal': true, 'allow_contact_removal': true,
'hide_offline_users': false, 'hide_offline_users': false,
...@@ -59075,10 +59076,6 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins ...@@ -59075,10 +59076,6 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins
afterRender() { afterRender() {
if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) { if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
this.initXHRAutoComplete(); this.initXHRAutoComplete();
this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
this.el.querySelector('input[name="name"]').value = ev.text.label;
this.el.querySelector('input[name="jid"]').value = ev.text.value;
});
} else { } else {
this.initJIDAutoComplete(); this.initJIDAutoComplete();
} }
...@@ -59088,6 +59085,10 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins ...@@ -59088,6 +59085,10 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins
}, },
initJIDAutoComplete() { initJIDAutoComplete() {
if (!_converse.autocomplete_add_contact) {
return;
}
const el = this.el.querySelector('.suggestion-box__jid').parentElement; const el = this.el.querySelector('.suggestion-box__jid').parentElement;
this.jid_auto_complete = new _converse.AutoComplete(el, { this.jid_auto_complete = new _converse.AutoComplete(el, {
'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`, 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
...@@ -59097,6 +59098,10 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins ...@@ -59097,6 +59098,10 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins
}, },
initXHRAutoComplete() { initXHRAutoComplete() {
if (!_converse.autocomplete_add_contact) {
return this.initXHRFetch();
}
const el = this.el.querySelector('.suggestion-box__name').parentElement; const el = this.el.querySelector('.suggestion-box__name').parentElement;
this.name_auto_complete = new _converse.AutoComplete(el, { this.name_auto_complete = new _converse.AutoComplete(el, {
'auto_evaluate': false, 'auto_evaluate': false,
...@@ -59122,28 +59127,76 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins ...@@ -59122,28 +59127,76 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_4__["default"].plugins
xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true); xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true);
xhr.send(); xhr.send();
}, 300)); }, 300));
this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
this.el.querySelector('input[name="name"]').value = ev.text.label;
this.el.querySelector('input[name="jid"]').value = ev.text.value;
});
}, },
addContactFromForm(ev) { initXHRFetch() {
ev.preventDefault(); this.xhr = new window.XMLHttpRequest();
const data = new FormData(ev.target),
jid = data.get('jid'), this.xhr.onload = () => {
name = data.get('name'); if (this.xhr.responseText) {
const r = this.xhr.responseText;
const list = JSON.parse(r).map(i => ({
'label': i.fullname || i.jid,
'value': i.jid
}));
if (list.length !== 1) {
const el = this.el.querySelector('.suggestion-box__name .invalid-feedback');
el.textContent = __('Sorry, could not find a contact with that name');
u.addClass('d-block', el);
return;
}
const jid = list[0].value;
if (this.validateSubmission(jid)) {
const form = this.el.querySelector('form');
const name = list[0].label;
this.afterSubmission(form, jid, name);
}
}
};
},
validateSubmission(jid) {
if (!jid || _.compact(jid.split('@')).length < 2) { if (!jid || _.compact(jid.split('@')).length < 2) {
// XXX: we used to have to do this manually, instead of via // XXX: we used to have to do this manually, instead of via
// toHTML because Awesomplete messes things up and // toHTML because Awesomplete messes things up and
// confuses Snabbdom // confuses Snabbdom
// We now use _converse.AutoComplete, can this be removed? // We now use _converse.AutoComplete, can this be removed?
u.addClass('is-invalid', this.el.querySelector('input[name="jid"]')); u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
u.addClass('d-block', this.el.querySelector('.invalid-feedback')); u.addClass('d-block', this.el.querySelector('.suggestion-box__jid .invalid-feedback'));
} else { return false;
ev.target.reset(); }
_converse.roster.addAndSubscribe(jid, name); return true;
},
this.model.clear(); afterSubmission(form, jid, name) {
this.modal.hide(); _converse.roster.addAndSubscribe(jid, name);
this.model.clear();
this.modal.hide();
},
addContactFromForm(ev) {
ev.preventDefault();
const data = new FormData(ev.target),
jid = data.get('jid');
if (!jid && _converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
const input_el = this.el.querySelector('input[name="name"]');
this.xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true);
this.xhr.send();
return;
}
if (this.validateSubmission(jid)) {
this.afterSubmission(ev.target, jid, data.get('name'));
} }
} }
...@@ -68597,7 +68650,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins ...@@ -68597,7 +68650,7 @@ _converse_headless_converse_core__WEBPACK_IMPORTED_MODULE_0__["default"].plugins
* (Function) callback - A function to call once the IQ is returned * (Function) callback - A function to call once the IQ is returned
* (Function) errback - A function to call if an error occurred * (Function) errback - A function to call if an error occurred
*/ */
name = _.isEmpty(name) ? jid : name; name = _.isEmpty(name) ? null : name;
const iq = $iq({ const iq = $iq({
'type': 'set' 'type': 'set'
}).c('query', { }).c('query', {
...@@ -92115,7 +92168,11 @@ __p += ' hidden '; ...@@ -92115,7 +92168,11 @@ __p += ' hidden ';
} ; } ;
__p += '">\n <label class="clearfix" for="jid">' + __p += '">\n <label class="clearfix" for="jid">' +
__e(o.label_xmpp_address) + __e(o.label_xmpp_address) +
':</label>\n <div class="suggestion-box suggestion-box__jid">\n <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>\n <input type="text" name="jid" required="required" value="' + ':</label>\n <div class="suggestion-box suggestion-box__jid">\n <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>\n <input type="text" name="jid"\n ';
if (!o._converse.xhr_user_search_url) { ;
__p += ' required="required" ';
} ;
__p += '\n value="' +
__e(o.jid) + __e(o.jid) +
'"\n class="form-control suggestion-box__input"\n placeholder="' + '"\n class="form-control suggestion-box__input"\n placeholder="' +
__e(o.contact_placeholder) + __e(o.contact_placeholder) +
...@@ -250,6 +250,13 @@ available) and the amount returned will be no more than the page size. ...@@ -250,6 +250,13 @@ available) and the amount returned will be no more than the page size.
You will be able to query for even older messages by scrolling upwards in the chatbox or room You will be able to query for even older messages by scrolling upwards in the chatbox or room
(the so-called infinite scrolling pattern). (the so-called infinite scrolling pattern).
autocomplete_add_contact
------------------------
* Default: ``true``
Determines whether search suggestions are shown in the "Add Contact" modal.
auto_list_rooms auto_list_rooms
--------------- ---------------
......
...@@ -218,13 +218,44 @@ ...@@ -218,13 +218,44 @@
done(); done();
})); }));
it("can be configured to not provide search suggestions",
mock.initConverse(
null, ['rosterGroupsFetched'], {'autocomplete_add_contact': false},
async function (done, _converse) {
test_utils.openControlBox();
const panel = _converse.chatboxviews.get('controlbox').contactspanel;
const cbview = _converse.chatboxviews.get('controlbox');
cbview.el.querySelector('.add-contact').click()
const modal = _converse.rosterview.add_contact_modal;
expect(modal.jid_auto_complete).toBe(undefined);
expect(modal.name_auto_complete).toBe(undefined);
await test_utils.waitUntil(() => u.isVisible(modal.el), 1000);
const sendIQ = _converse.connection.sendIQ;
let sent_stanza;
spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
sent_stanza = iq;
sendIQ.bind(this)(iq, callback, errback);
});
expect(!_.isNull(modal.el.querySelector('form.add-xmpp-contact'))).toBeTruthy();
const input_jid = modal.el.querySelector('input[name="jid"]');
const input_name = modal.el.querySelector('input[name="name"]');
input_jid.value = 'someone@localhost';
modal.el.querySelector('button[type="submit"]').click();
await test_utils.waitUntil(() => sent_stanza.nodeTree.matches('iq'));
expect(sent_stanza.toLocaleString()).toEqual(
`<iq id="${sent_stanza.nodeTree.getAttribute('id')}" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:roster"><item jid="someone@localhost"/></query>`+
`</iq>`);
done();
}));
it("integrates with xhr_user_search_url to search for contacts", it("integrates with xhr_user_search_url to search for contacts",
mock.initConverse( mock.initConverse(
null, ['rosterGroupsFetched'], null, ['rosterGroupsFetched'],
{ 'xhr_user_search': true, { 'xhr_user_search_url': 'http://example.org/?' },
'xhr_user_search_url': 'http://example.org/?'
},
async function (done, _converse) { async function (done, _converse) {
const xhr = { const xhr = {
...@@ -278,5 +309,74 @@ ...@@ -278,5 +309,74 @@
window.XMLHttpRequest = XMLHttpRequestBackup; window.XMLHttpRequest = XMLHttpRequestBackup;
done(); done();
})); }));
it("can be configured to not provide search suggestions for XHR search results",
mock.initConverse(
null, ['rosterGroupsFetched'],
{ 'autocomplete_add_contact': false,
'xhr_user_search_url': 'http://example.org/?' },
async function (done, _converse) {
var modal;
const xhr = {
'open': _.noop,
'send': function () {
const value = modal.el.querySelector('input[name="name"]').value;
if (value === 'ambiguous') {
xhr.responseText = JSON.stringify([
{"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
{"jid": "doc@brown.com", "fullname": "Doc Brown"}
]);
} else if (value === 'insufficient') {
xhr.responseText = JSON.stringify([]);
} else {
xhr.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
}
xhr.onload();
}
};
const XMLHttpRequestBackup = window.XMLHttpRequest;
window.XMLHttpRequest = jasmine.createSpy('XMLHttpRequest');
XMLHttpRequest.and.callFake(() => xhr);
const panel = _converse.chatboxviews.get('controlbox').contactspanel;
const cbview = _converse.chatboxviews.get('controlbox');
cbview.el.querySelector('.add-contact').click()
modal = _converse.rosterview.add_contact_modal;
await test_utils.waitUntil(() => u.isVisible(modal.el), 1000);
expect(modal.jid_auto_complete).toBe(undefined);
expect(modal.name_auto_complete).toBe(undefined);
const sendIQ = _converse.connection.sendIQ;
let sent_stanza, IQ_id;
spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) {
sent_stanza = iq;
IQ_id = sendIQ.bind(this)(iq, callback, errback);
});
const input_el = modal.el.querySelector('input[name="name"]');
input_el.value = 'ambiguous';
modal.el.querySelector('button[type="submit"]').click();
let feedback_el = modal.el.querySelector('.suggestion-box__name .invalid-feedback');
expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
feedback_el.textContent = '';
input_el.value = 'insufficient';
modal.el.querySelector('button[type="submit"]').click();
feedback_el = modal.el.querySelector('.suggestion-box__name .invalid-feedback');
expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
input_el.value = 'Marty McFly';
modal.el.querySelector('button[type="submit"]').click();
expect(sent_stanza.toLocaleString()).toEqual(
`<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
`</iq>`);
window.XMLHttpRequest = XMLHttpRequestBackup;
done();
}));
}); });
})); }));
...@@ -117,7 +117,7 @@ ...@@ -117,7 +117,7 @@
expect(sent_stanza.toLocaleString()).toBe( expect(sent_stanza.toLocaleString()).toBe(
`<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+
`<query xmlns="jabber:iq:roster">`+ `<query xmlns="jabber:iq:roster">`+
`<item jid="contact@example.org" name="contact@example.org"/>`+ `<item jid="contact@example.org"/>`+
`</query>`+ `</query>`+
`</iq>` `</iq>`
); );
......
...@@ -55,13 +55,14 @@ converse.plugins.add('converse-rosterview', { ...@@ -55,13 +55,14 @@ converse.plugins.add('converse-rosterview', {
{ __ } = _converse; { __ } = _converse;
_converse.api.settings.update({ _converse.api.settings.update({
'autocomplete_add_contact': true,
'allow_chat_pending_contacts': true, 'allow_chat_pending_contacts': true,
'allow_contact_removal': true, 'allow_contact_removal': true,
'hide_offline_users': false, 'hide_offline_users': false,
'roster_groups': true, 'roster_groups': true,
'show_only_online_users': false, 'show_only_online_users': false,
'show_toolbar': true, 'show_toolbar': true,
'xhr_user_search_url': null 'xhr_user_search_url': null,
}); });
_converse.api.promises.add('rosterViewInitialized'); _converse.api.promises.add('rosterViewInitialized');
...@@ -132,10 +133,6 @@ converse.plugins.add('converse-rosterview', { ...@@ -132,10 +133,6 @@ converse.plugins.add('converse-rosterview', {
afterRender () { afterRender () {
if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) { if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
this.initXHRAutoComplete(); this.initXHRAutoComplete();
this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
this.el.querySelector('input[name="name"]').value = ev.text.label;
this.el.querySelector('input[name="jid"]').value = ev.text.value;
});
} else { } else {
this.initJIDAutoComplete(); this.initJIDAutoComplete();
} }
...@@ -144,6 +141,9 @@ converse.plugins.add('converse-rosterview', { ...@@ -144,6 +141,9 @@ converse.plugins.add('converse-rosterview', {
}, },
initJIDAutoComplete () { initJIDAutoComplete () {
if (!_converse.autocomplete_add_contact) {
return;
}
const el = this.el.querySelector('.suggestion-box__jid').parentElement; const el = this.el.querySelector('.suggestion-box__jid').parentElement;
this.jid_auto_complete = new _converse.AutoComplete(el, { this.jid_auto_complete = new _converse.AutoComplete(el, {
'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`, 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
...@@ -153,6 +153,9 @@ converse.plugins.add('converse-rosterview', { ...@@ -153,6 +153,9 @@ converse.plugins.add('converse-rosterview', {
}, },
initXHRAutoComplete () { initXHRAutoComplete () {
if (!_converse.autocomplete_add_contact) {
return this.initXHRFetch();
}
const el = this.el.querySelector('.suggestion-box__name').parentElement; const el = this.el.querySelector('.suggestion-box__name').parentElement;
this.name_auto_complete = new _converse.AutoComplete(el, { this.name_auto_complete = new _converse.AutoComplete(el, {
'auto_evaluate': false, 'auto_evaluate': false,
...@@ -174,25 +177,66 @@ converse.plugins.add('converse-rosterview', { ...@@ -174,25 +177,66 @@ converse.plugins.add('converse-rosterview', {
xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true); xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true);
xhr.send() xhr.send()
} , 300)); } , 300));
this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
this.el.querySelector('input[name="name"]').value = ev.text.label;
this.el.querySelector('input[name="jid"]').value = ev.text.value;
});
}, },
addContactFromForm (ev) { initXHRFetch () {
ev.preventDefault(); this.xhr = new window.XMLHttpRequest();
const data = new FormData(ev.target), this.xhr.onload = () => {
jid = data.get('jid'), if (this.xhr.responseText) {
name = data.get('name'); const r = this.xhr.responseText;
const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
if (list.length !== 1) {
const el = this.el.querySelector('.suggestion-box__name .invalid-feedback');
el.textContent = __('Sorry, could not find a contact with that name')
u.addClass('d-block', el);
return;
}
const jid = list[0].value;
if (this.validateSubmission(jid)) {
const form = this.el.querySelector('form');
const name = list[0].label;
this.afterSubmission(form, jid, name);
}
}
};
},
validateSubmission (jid) {
if (!jid || _.compact(jid.split('@')).length < 2) { if (!jid || _.compact(jid.split('@')).length < 2) {
// XXX: we used to have to do this manually, instead of via // XXX: we used to have to do this manually, instead of via
// toHTML because Awesomplete messes things up and // toHTML because Awesomplete messes things up and
// confuses Snabbdom // confuses Snabbdom
// We now use _converse.AutoComplete, can this be removed? // We now use _converse.AutoComplete, can this be removed?
u.addClass('is-invalid', this.el.querySelector('input[name="jid"]')); u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
u.addClass('d-block', this.el.querySelector('.invalid-feedback')); u.addClass('d-block', this.el.querySelector('.suggestion-box__jid .invalid-feedback'));
} else { return false;
ev.target.reset(); }
_converse.roster.addAndSubscribe(jid, name); return true;
this.model.clear(); },
this.modal.hide();
afterSubmission (form, jid, name) {
_converse.roster.addAndSubscribe(jid, name);
this.model.clear();
this.modal.hide();
},
addContactFromForm (ev) {
ev.preventDefault();
const data = new FormData(ev.target),
jid = data.get('jid');
if (!jid && _converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
const input_el = this.el.querySelector('input[name="name"]');
this.xhr.open("GET", `${_converse.xhr_user_search_url}q=${input_el.value}`, true);
this.xhr.send()
return;
}
if (this.validateSubmission(jid)) {
this.afterSubmission(ev.target, jid, data.get('name'));
} }
} }
}); });
......
...@@ -453,7 +453,7 @@ converse.plugins.add('converse-roster', { ...@@ -453,7 +453,7 @@ converse.plugins.add('converse-roster', {
* (Function) callback - A function to call once the IQ is returned * (Function) callback - A function to call once the IQ is returned
* (Function) errback - A function to call if an error occurred * (Function) errback - A function to call if an error occurred
*/ */
name = _.isEmpty(name)? jid: name; name = _.isEmpty(name) ? null : name;
const iq = $iq({'type': 'set'}) const iq = $iq({'type': 'set'})
.c('query', {'xmlns': Strophe.NS.ROSTER}) .c('query', {'xmlns': Strophe.NS.ROSTER})
.c('item', { jid, name }); .c('item', { jid, name });
......
...@@ -12,7 +12,9 @@ ...@@ -12,7 +12,9 @@
<label class="clearfix" for="jid">{{{o.label_xmpp_address}}}:</label> <label class="clearfix" for="jid">{{{o.label_xmpp_address}}}:</label>
<div class="suggestion-box suggestion-box__jid"> <div class="suggestion-box suggestion-box__jid">
<ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul> <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
<input type="text" name="jid" required="required" value="{{{o.jid}}}" <input type="text" name="jid"
{[ if (!o._converse.xhr_user_search_url) { ]} required="required" {[ } ]}
value="{{{o.jid}}}"
class="form-control suggestion-box__input" class="form-control suggestion-box__input"
placeholder="{{{o.contact_placeholder}}}"/> placeholder="{{{o.contact_placeholder}}}"/>
<div class="invalid-feedback">{{{o.error_message}}}</div> <div class="invalid-feedback">{{{o.error_message}}}</div>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment