Browse Source

Fixes #3137

- Modernize the `RegisterPanel` component and turn it into a Lit element.
- Improve CSS and move into plugin.
- Fix button click handler not being registered.
- Fix switching between login/register form after logging out (Fixes #1556)
JC Brand 2 years ago
parent
commit
7b8b32638c

+ 5 - 0
CHANGES.md

@@ -1,5 +1,10 @@
 # Changelog
 
+## 10.1.2 (Unreleased)
+
+- #1556: Can't switch to registration form afrer logout
+- #3137: Various UI/UX bugfixes regarding the registration form
+
 ## 10.1.1 (2023-02-15)
 
 - #1851: Sort open groupchats alphabetically

+ 1 - 1
src/plugins/controlbox/index.js

@@ -60,7 +60,7 @@ converse.plugins.add('converse-controlbox', {
             sticky_controlbox: false
         });
 
-        api.promises.add('controlBoxInitialized');
+        api.promises.add('controlBoxInitialized', false);
         Object.assign(api, controlbox_api);
 
         _converse.ControlBoxView = ControlBoxView;

+ 0 - 50
src/plugins/controlbox/styles/_controlbox.scss

@@ -109,56 +109,6 @@
             font-weight: bold;
         }
 
-        #converse-register {
-            @include fade-in;
-            background-color: var(--controlbox-pane-background-color);
-            .title {
-                font-weight: bold;
-            }
-            .info {
-                color: green;
-                font-size: 90%;
-                margin: 1.5em 0;
-            }
-            .form-errors {
-                color: var(--error-color);
-                margin: 1em 0;
-            }
-            .provider-title {
-                font-size: var(--font-size-huge);
-                margin: 0;
-            }
-            .provider-score {
-                width: 178px;
-                margin-bottom: 8px;
-            }
-            .form-help .url {
-                font-weight: bold;
-                color: var(--link-color);
-            }
-            .input-group {
-                display: table;
-                margin: auto;
-                width: 100%;
-                span {
-                    overflow-x: hidden;
-                    text-overflow: ellipsis;
-                    max-width: 110px;
-                }
-                span, input[name=username] {
-                    display: table-cell;
-                    text-align: left;
-                }
-            }
-            .instructions {
-                color: gray;
-                font-size: 85%;
-                &:hover {
-                    color: var(--controlbox-text-color);
-                }
-            }
-        }
-
         .conn-feedback {
             color: var(--controlbox-head-color);
             &.error {

+ 3 - 3
src/plugins/controlbox/templates/loginform.js

@@ -60,7 +60,7 @@ const password_input = () => {
     `;
 }
 
-const register_link = () => {
+const tplRegisterLink = () => {
     const i18n_create_account = __("Create an account");
     const i18n_hint_no_account = __("Don't have a chat account?");
     return html`
@@ -71,7 +71,7 @@ const register_link = () => {
     `;
 }
 
-const show_register_link = () => {
+const tplShowRegisterLink = () => {
     return api.settings.get('allow_registration') &&
         !api.settings.get("auto_login") &&
         _converse.pluggable.plugins['converse-register'].enabled(_converse);
@@ -106,7 +106,7 @@ const auth_fields = (el) => {
         <fieldset class="form-group buttons">
             <input class="btn btn-primary" type="submit" value="${i18n_login}"/>
         </fieldset>
-        ${ show_register_link() ? register_link() : '' }
+        ${ tplShowRegisterLink() ? tplRegisterLink(el) : '' }
     `;
 }
 

+ 5 - 12
src/plugins/register/index.js

@@ -9,6 +9,7 @@
 import './panel.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from '@converse/headless/core';
+import { setActiveForm } from './utils.js';
 
 // Strophe methods for building stanzas
 const { Strophe } = converse.env;
@@ -32,6 +33,8 @@ converse.plugins.add('converse-register', {
     },
 
     initialize () {
+        const { router } = _converse;
+
         _converse.CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
         _converse.CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
         _converse.CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
@@ -44,17 +47,7 @@ converse.plugins.add('converse-register', {
             'registration_domain': ''
         });
 
-        async function setActiveForm (value) {
-            await api.waitUntil('controlBoxInitialized');
-            const controlbox = _converse.chatboxes.get('controlbox');
-            controlbox.set({ 'active-form': value });
-        }
-        _converse.router.route('converse/login', () => setActiveForm('login'));
-        _converse.router.route('converse/register', () => setActiveForm('register'));
-
-
-        api.listen.on('controlBoxInitialized', view => {
-            view.model.on('change:active-form', view.showLoginOrRegisterForm, view);
-        });
+        router.route('converse/login', () => setActiveForm('login'));
+        router.route('converse/register', () => setActiveForm('register'));
     }
 });

+ 42 - 94
src/plugins/register/panel.js

@@ -1,16 +1,18 @@
 import log from "@converse/headless/log";
-import pick from "lodash-es/pick";
 import tplFormInput from "templates/form_input.js";
 import tplFormUrl from "templates/form_url.js";
 import tplFormUsername from "templates/form_username.js";
 import tplRegisterPanel from "./templates/register_panel.js";
 import tplSpinner from "templates/spinner.js";
-import { webForm2xForm } from "@converse/headless/utils/form";
-import { ElementView } from "@converse/skeletor/src/element";
+import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core.js";
 import { initConnection } from '@converse/headless/utils/init.js';
 import { render } from 'lit';
+import { setActiveForm } from './utils.js';
+import { webForm2xForm } from "@converse/headless/utils/form";
+
+import './styles/register.scss';
 
 // Strophe methods for building stanzas
 const { Strophe, sizzle, $iq } = converse.env;
@@ -27,38 +29,29 @@ const REGISTRATION_FORM = 2;
  * @namespace _converse.RegisterPanel
  * @memberOf _converse
  */
-class RegisterPanel extends ElementView {
-    id = "converse-register-panel"
-    className = 'controlbox-pane fade-in'
-    events = {
-        'submit form#converse-register': 'onFormSubmission',
-        'click .button-cancel': 'renderProviderChoiceForm',
+class RegisterPanel extends CustomElement {
+
+    static get properties () {
+        return {
+            status : { type: String },
+            error_message: { type: String },
+        }
     }
 
     initialize () {
         this.reset();
-        const controlbox = _converse.chatboxes.get('controlbox');
-        this.model = controlbox;
-        this.listenTo(_converse, 'connectionInitialized', this.registerHooks);
-        this.listenTo(this.model, 'change:registration_status', this.render);
+        this.listenTo(_converse, 'connectionInitialized', () => this.registerHooks());
 
         const domain = api.settings.get('registration_domain');
         if (domain) {
             this.fetchRegistrationForm(domain);
         } else {
-            this.model.set('registration_status', CHOOSE_PROVIDER);
+            this.status = CHOOSE_PROVIDER;
         }
     }
 
     render () {
-        render(tplRegisterPanel({
-            'domain': this.domain,
-            'fields': this.fields,
-            'form_fields': this.form_fields,
-            'instructions': this.instructions,
-            'model': this.model,
-            'title': this.title,
-        }), this);
+        return tplRegisterPanel(this);
     }
 
     /**
@@ -79,11 +72,6 @@ class RegisterPanel extends ElementView {
         };
     }
 
-    connectedCallback () {
-        super.connectedCallback();
-        this.render();
-    }
-
     /**
      * Send an IQ stanza to the XMPP server asking for the registration fields.
      * @private
@@ -98,9 +86,8 @@ class RegisterPanel extends ElementView {
         const body = conn._proto._reqToData(req);
         if (!body) { return; }
         if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
-            this.showValidationError(
-                __("Sorry, we're unable to connect to your chosen provider.")
-            );
+            this.status = CHOOSE_PROVIDER;
+            this.error_message = __("Sorry, we're unable to connect to your chosen provider.");
             return false;
         }
         const register = body.getElementsByTagName("register");
@@ -111,14 +98,14 @@ class RegisterPanel extends ElementView {
         }
         if (register.length === 0) {
             conn._changeConnectStatus(Strophe.Status.REGIFAIL);
-            this.showValidationError(
+            this.error_message =
                 __("Sorry, the given provider does not support in "+
                    "band account registration. Please try with a "+
-                   "different provider."))
+                   "different provider.");
             return true;
         }
         // Send an IQ stanza to get all required data fields
-        conn._addSysHandler(this.onRegistrationFields.bind(this), null, "iq", null, null);
+        conn._addSysHandler((s) => this.onRegistrationFields(s), null, "iq", null, null);
         const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
         stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
         conn.send(stanza);
@@ -149,7 +136,7 @@ class RegisterPanel extends ElementView {
             return false;
         }
         this.setFields(stanza);
-        if (this.model.get('registration_status') === FETCHING_FORM) {
+        if (this.status === FETCHING_FORM) {
             this.renderRegistrationForm(stanza);
         }
         return false;
@@ -167,9 +154,7 @@ class RegisterPanel extends ElementView {
             form_type: null
         };
         Object.assign(this, defaults);
-        if (settings) {
-            Object.assign(this, pick(settings, Object.keys(defaults)));
-        }
+        if (settings) Object.assign(this, settings);
     }
 
     /**
@@ -179,7 +164,7 @@ class RegisterPanel extends ElementView {
      * @param { Event } ev
      */
     onFormSubmission (ev) {
-        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        ev?.preventDefault?.();
         if (ev.target.querySelector('input[name=domain]') === null) {
             this.submitRegistrationForm(ev.target);
         } else {
@@ -195,15 +180,8 @@ class RegisterPanel extends ElementView {
      * @param { HTMLElement } form - The form that was submitted
      */
     onProviderChosen (form) {
-        const domain_input = form.querySelector('input[name=domain]'),
-            domain = domain_input?.value;
-        if (!domain) {
-            // TODO: add validation message
-            domain_input.classList.add('error');
-            return;
-        }
-        form.querySelector('input[type=submit]').classList.add('hidden');
-        this.fetchRegistrationForm(domain.trim());
+        const domain = form.querySelector('input[name=domain]')?.value;
+        if (domain) this.fetchRegistrationForm(domain.trim());
     }
 
     /**
@@ -213,7 +191,7 @@ class RegisterPanel extends ElementView {
      * @param { String } domain_name - XMPP server domain
      */
     fetchRegistrationForm (domain_name) {
-        this.model.set('registration_status', FETCHING_FORM);
+        this.status = FETCHING_FORM;
         this.reset({
             'domain': Strophe.getDomainFromJid(domain_name),
             '_registering': true
@@ -221,7 +199,7 @@ class RegisterPanel extends ElementView {
         initConnection(this.domain);
         // When testing, the test tears down before the async function
         // above finishes. So we use optional chaining here
-        _converse.connection?.connect(this.domain, "", status => this.onConnectStatusChanged(status));
+        _converse.connection?.connect(this.domain, "", (s) => this.onConnectStatusChanged(s));
         return false;
     }
 
@@ -281,6 +259,8 @@ class RegisterPanel extends ElementView {
                     this.fields.password,
                     _converse.onConnectStatusChanged
                 );
+                setActiveForm('login');
+                _converse.router.navigate('');
                 this.giveFeedback(__('Now logging you in'), 'info');
             } else {
                 _converse.giveFeedback(__('Registered successfully'));
@@ -334,29 +314,7 @@ class RegisterPanel extends ElementView {
      */
     renderRegistrationForm (stanza) {
         this.form_fields = this.getFormFields(stanza);
-        this.model.set('registration_status', REGISTRATION_FORM);
-    }
-
-    showValidationError (message) {
-        const form = this.querySelector('form');
-        let flash = form.querySelector('.form-errors');
-        if (flash === null) {
-            flash = '<div class="form-errors hidden"></div>';
-            const instructions = form.querySelector('p.instructions');
-            if (instructions === null) {
-                form.insertAdjacentHTML('afterbegin', flash);
-            } else {
-                instructions.insertAdjacentHTML('afterend', flash);
-            }
-            flash = form.querySelector('.form-errors');
-        } else {
-            flash.innerHTML = '';
-        }
-        flash.insertAdjacentHTML(
-            'beforeend',
-            '<p class="form-help error">'+message+'</p>'
-        );
-        flash.classList.remove('hidden');
+        this.status = REGISTRATION_FORM;
     }
 
     /**
@@ -367,31 +325,31 @@ class RegisterPanel extends ElementView {
      * @param { XMLElement } stanza - The IQ stanza received from the XMPP server
      */
     reportErrors (stanza) {
-        const errors = stanza.querySelectorAll('error');
-        errors.forEach(e => this.showValidationError(e.textContent));
-        if (!errors.length) {
-            const message = __('The provider rejected your registration attempt. '+
+        const errors = Array.from(stanza.querySelectorAll('error'));
+        if (errors.length) {
+            this.error_message = errors.reduce((result, e) => `${result}\n${e.textContent}`, '');
+        } else {
+            this.error_message = __('The provider rejected your registration attempt. '+
                 'Please check the values you entered for correctness.');
-            this.showValidationError(message);
         }
     }
 
     renderProviderChoiceForm (ev) {
-        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        ev?.preventDefault?.();
         _converse.connection._proto._abortAllRequests();
         _converse.connection.reset();
-        this.render();
+        this.status = CHOOSE_PROVIDER;
     }
 
     abortRegistration () {
         _converse.connection._proto._abortAllRequests();
         _converse.connection.reset();
-        if ([FETCHING_FORM, REGISTRATION_FORM].includes(this.model.get('registration_status'))) {
+        if ([FETCHING_FORM, REGISTRATION_FORM].includes(this.status)) {
             if (api.settings.get('registration_domain')) {
                 this.fetchRegistrationForm(api.settings.get('registration_domain'));
             }
         } else {
-            this.render();
+            this.requestUpdate();
         }
     }
 
@@ -403,16 +361,6 @@ class RegisterPanel extends ElementView {
      * @param { HTMLElement } form - The HTML form that was submitted
      */
     submitRegistrationForm (form) {
-        const has_empty_inputs = Array.from(this.querySelectorAll('input.required'))
-            .reduce((result, input) => {
-                if (input.value === '') {
-                    input.classList.add('error');
-                    return result + 1;
-                }
-                return result;
-            }, 0);
-        if (has_empty_inputs) { return; }
-
         const inputs = sizzle(':input:not([type=button]):not([type=submit])', form);
         const iq = $iq({'type': 'set', 'id': u.getUniqueId()})
                     .c("query", {xmlns:Strophe.NS.REGISTER});
@@ -425,7 +373,7 @@ class RegisterPanel extends ElementView {
         } else {
             inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));
         }
-        _converse.connection._addSysHandler(this._onRegisterIQ.bind(this), null, "iq", null, null);
+        _converse.connection._addSysHandler((iq) => this._onRegisterIQ(iq), null, "iq", null, null);
         _converse.connection.send(iq);
         this.setFields(iq.tree());
     }
@@ -462,8 +410,8 @@ class RegisterPanel extends ElementView {
     }
 
     _setFieldsFromXForm (xform) {
-        this.title = xform.querySelector('title')?.textContent;
-        this.instructions = xform.querySelector('instructions')?.textContent;
+        this.title = xform.querySelector('title')?.textContent ?? '';
+        this.instructions = xform.querySelector('instructions')?.textContent ?? '';
         xform.querySelectorAll('field').forEach(field => {
             const _var = field.getAttribute('var');
             if (_var) {

+ 61 - 0
src/plugins/register/styles/register.scss

@@ -0,0 +1,61 @@
+@import "shared/styles/_mixins.scss";
+
+converse-register-panel {
+    .alert {
+        margin: auto;
+        max-width: 50vw;
+    }
+}
+
+#converse-register {
+    @include fade-in;
+    background-color: var(--controlbox-pane-background-color);
+
+    .title {
+        font-weight: bold;
+    }
+
+    .input-group {
+        input {
+            height: auto;
+        }
+        .input-group-text {
+            color: var(--text-color);
+            background-color: var(--controlbox-pane-background-color);
+        }
+    }
+
+    .info {
+        color: green;
+        font-size: 90%;
+        margin: 1.5em 0;
+    }
+
+    .form-errors {
+        color: var(--error-color);
+        margin: 1em 0;
+    }
+
+    .provider-title {
+        font-size: var(--font-size-huge);
+        margin: 0;
+    }
+
+    .provider-score {
+        width: 178px;
+        margin-bottom: 8px;
+    }
+
+    .form-help .url {
+        font-weight: bold;
+        color: var(--link-color);
+    }
+
+    .instructions {
+        color: gray;
+        font-size: 85%;
+        &:hover {
+            color: var(--controlbox-text-color);
+        }
+    }
+}

+ 15 - 11
src/plugins/register/templates/register_panel.js

@@ -4,18 +4,19 @@ import { __ } from 'i18n';
 import { api } from '@converse/headless/core';
 import { html } from 'lit';
 
-const tplFormRequest = () => {
+const tplFormRequest = (el) => {
     const default_domain = api.settings.get('registration_domain');
     const i18n_fetch_form = __("Hold tight, we're fetching the registration form…");
     const i18n_cancel = __('Cancel');
     return html`
-        <form id="converse-register" class="converse-form no-scrolling">
+        <form id="converse-register" class="converse-form no-scrolling" @submit=${ev => el.onFormSubmission(ev)}>
             ${tplSpinner({ 'classes': 'hor_centered' })}
             <p class="info">${i18n_fetch_form}</p>
             ${default_domain
                 ? ''
                 : html`
-                      <button class="btn btn-secondary button-cancel hor_centered">${i18n_cancel}</button>
+                    <button class="btn btn-secondary button-cancel hor_centered"
+                            @click=${ev => el.renderProviderChoiceForm(ev)}>${i18n_cancel}</button>
                   `}
         </form>
     `;
@@ -50,19 +51,21 @@ const tplFetchFormButtons = () => {
     `;
 };
 
-const tplChooseProvider = () => {
+const tplChooseProvider = (el) => {
     const default_domain = api.settings.get('registration_domain');
     const i18n_create_account = __('Create your account');
     const i18n_choose_provider = __('Please enter the XMPP provider to register with:');
+    const show_form_buttons = !default_domain && el.status === CHOOSE_PROVIDER;
+
     return html`
-        <form id="converse-register" class="converse-form">
+        <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}>
             <legend class="col-form-label">${i18n_create_account}</legend>
             <div class="form-group">
                 <label>${i18n_choose_provider}</label>
-                <div class="form-errors hidden"></div>
+
                 ${default_domain ? default_domain : tplDomainInput()}
             </div>
-            ${default_domain ? '' : tplFetchFormButtons()}
+            ${show_form_buttons ? tplFetchFormButtons() : ''}
         </form>
     `;
 };
@@ -71,11 +74,12 @@ const CHOOSE_PROVIDER = 0;
 const FETCHING_FORM = 1;
 const REGISTRATION_FORM = 2;
 
-export default o => {
+export default (el) => {
     return html`
         <converse-brand-logo></converse-brand-logo>
-        ${o.model.get('registration_status') === CHOOSE_PROVIDER ? tplChooseProvider() : ''}
-        ${o.model.get('registration_status') === FETCHING_FORM ? tplFormRequest() : ''}
-        ${o.model.get('registration_status') === REGISTRATION_FORM ? tplRegistrationForm(o) : ''}
+        ${ el.error_message ? html`<div class="alert alert-danger" role="alert">${el.error_message}</div>` : '' }
+        ${el.status === CHOOSE_PROVIDER ? tplChooseProvider(el) : ''}
+        ${el.status === FETCHING_FORM ? tplFormRequest(el) : ''}
+        ${el.status === REGISTRATION_FORM ? tplRegistrationForm(el) : ''}
     `;
 };

+ 8 - 7
src/plugins/register/templates/registration_form.js

@@ -2,7 +2,7 @@ import { __ } from 'i18n';
 import { api } from '@converse/headless/core';
 import { html } from 'lit';
 
-export default o => {
+export default (el) => {
     const i18n_choose_provider = __('Choose a different provider');
     const i18n_has_account = __('Already have a chat account?');
     const i18n_legend = __('Account Registration:');
@@ -11,15 +11,15 @@ export default o => {
     const registration_domain = api.settings.get('registration_domain');
 
     return html`
-        <form id="converse-register" class="converse-form">
-            <legend class="col-form-label">${i18n_legend} ${o.domain}</legend>
-            <p class="title">${o.title}</p>
-            <p class="form-help instructions">${o.instructions}</p>
+        <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}>
+            <legend class="col-form-label">${i18n_legend} ${el.domain}</legend>
+            <p class="title">${el.title}</p>
+            <p class="form-help instructions">${el.instructions}</p>
             <div class="form-errors hidden"></div>
-            ${o.form_fields}
+            ${el.form_fields}
 
             <fieldset class="buttons form-group">
-                ${o.fields
+                ${el.fields
                     ? html`
                           <input type="submit" class="btn btn-primary" value="${i18n_register}" />
                       `
@@ -31,6 +31,7 @@ export default o => {
                               type="button"
                               class="btn btn-secondary button-cancel"
                               value="${i18n_choose_provider}"
+                              @click=${ev => el.renderProviderChoiceForm(ev)}
                           />
                       `}
                 <div class="switch-form">

+ 146 - 29
src/plugins/register/tests/register.js

@@ -1,9 +1,7 @@
 /*global mock, converse */
 
-const Strophe = converse.env.Strophe;
-const $iq = converse.env.$iq;
-const { sizzle}  = converse.env;
-const u = converse.env.utils;
+const { stx, Strophe, $iq, sizzle, u } = converse.env;
+
 
 describe("The Registration Panel", function () {
 
@@ -88,6 +86,61 @@ describe("The Registration Panel", function () {
         delete _converse.connection;
     }));
 
+    it("allows the user to choose an XMPP provider's domain in fullscreen view mode",
+        mock.initConverse(
+            ['chatBoxesInitialized'], {
+                auto_login: false,
+                view_mode: 'fullscreen',
+                discover_connection_methods: false,
+                allow_registration: true
+            },
+            async function (_converse) {
+
+        const cbview = _converse.api.controlbox.get();
+        cbview.querySelector('.toggle-register-login').click();
+
+        const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+        spyOn(registerview, 'fetchRegistrationForm').and.callThrough();
+        spyOn(registerview, 'onProviderChosen').and.callThrough();
+        spyOn(registerview, 'getRegistrationFields').and.callThrough();
+        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
+
+        expect(registerview._registering).toBeFalsy();
+        expect(_converse.api.connection.connected()).toBeFalsy();
+        registerview.querySelector('input[name=domain]').value  = 'conversejs.org';
+        registerview.querySelector('input[type=submit]').click();
+        expect(registerview.onProviderChosen).toHaveBeenCalled();
+        expect(registerview._registering).toBeTruthy();
+
+        await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count());
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+
+        expect(registerview.getRegistrationFields).toHaveBeenCalled();
+
+        stanza = $iq({
+                'type': 'result',
+                'id': 'reg1'
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('instructions')
+                    .t('Please choose a username, password and provide your email address').up()
+                .c('username').up()
+                .c('password').up()
+                .c('email');
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(registerview.renderRegistrationForm).toHaveBeenCalled();
+
+        await u.waitUntil(() => registerview.querySelectorAll('input').length === 5);
+        expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1);
+        expect(registerview.querySelectorAll('input[type=button]').length).toBe(1);
+    }));
+
     it("will render a registration form as received from the XMPP provider",
         mock.initConverse(
             ['chatBoxesInitialized'],
@@ -108,7 +161,6 @@ describe("The Registration Panel", function () {
         spyOn(registerview, 'getRegistrationFields').and.callThrough();
         spyOn(registerview, 'onRegistrationFields').and.callThrough();
         spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
 
         expect(registerview._registering).toBeFalsy();
         expect(_converse.api.connection.connected()).toBeFalsy();
@@ -140,7 +192,8 @@ describe("The Registration Panel", function () {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         expect(registerview.onRegistrationFields).toHaveBeenCalled();
         expect(registerview.renderRegistrationForm).toHaveBeenCalled();
-        expect(registerview.querySelectorAll('input').length).toBe(5);
+
+        await u.waitUntil(() => registerview.querySelectorAll('input').length === 5);
         expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1);
         expect(registerview.querySelectorAll('input[type=button]').length).toBe(1);
     }));
@@ -168,7 +221,6 @@ describe("The Registration Panel", function () {
         spyOn(registerview, 'getRegistrationFields').and.callThrough();
         spyOn(registerview, 'onRegistrationFields').and.callThrough();
         spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
 
         registerview.querySelector('input[name=domain]').value = 'conversejs.org';
         registerview.querySelector('input[type=submit]').click();
@@ -192,7 +244,9 @@ describe("The Registration Panel", function () {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         expect(registerview.form_type).toBe('legacy');
 
-        registerview.querySelector('input[name=username]').value = 'testusername';
+        const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]'));
+
+        username_input.value = 'testusername';
         registerview.querySelector('input[name=password]').value = 'testpassword';
         registerview.querySelector('input[name=email]').value = 'test@email.local';
 
@@ -229,7 +283,6 @@ describe("The Registration Panel", function () {
         spyOn(registerview, 'getRegistrationFields').and.callThrough();
         spyOn(registerview, 'onRegistrationFields').and.callThrough();
         spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
 
         registerview.querySelector('input[name=domain]').value = 'conversejs.org';
         registerview.querySelector('input[type=submit]').click();
@@ -255,7 +308,9 @@ describe("The Registration Panel", function () {
         _converse.connection._dataRecv(mock.createRequest(stanza));
         expect(registerview.form_type).toBe('xform');
 
-        registerview.querySelector('input[name=username]').value = 'testusername';
+        const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]'));
+
+        username_input.value = 'testusername';
         registerview.querySelector('input[name=password]').value = 'testpassword';
         registerview.querySelector('input[name=email]').value = 'test@email.local';
 
@@ -304,12 +359,6 @@ describe("The Registration Panel", function () {
         const cbview = _converse.chatboxviews.get('controlbox');
         cbview.querySelector('.toggle-register-login').click();
         const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
-        spyOn(registerview, 'onProviderChosen').and.callThrough();
-        spyOn(registerview, 'getRegistrationFields').and.callThrough();
-        spyOn(registerview, 'onRegistrationFields').and.callThrough();
-        spyOn(registerview, 'renderRegistrationForm').and.callThrough();
-        registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-
         registerview.querySelector('input[name=domain]').value = 'conversejs.org';
         registerview.querySelector('input[type=submit]').click();
 
@@ -321,7 +370,7 @@ describe("The Registration Panel", function () {
             .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
         _converse.connection._connect_cb(mock.createRequest(stanza));
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
                 <query xmlns="jabber:iq:register">
                     <x xmlns="jabber:x:data" type="form">
@@ -344,13 +393,76 @@ describe("The Registration Panel", function () {
                           max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
                     <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
                 </query>
-            </iq>`);
+            </iq>`;
         _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 3);
         expect(registerview.form_type).toBe('xform');
-        expect(registerview.querySelectorAll('#converse-register input[required]').length).toBe(3);
-        // Hide the controlbox so that we can see whether the test
-        // passed or failed
-        u.addClass('hidden', _converse.chatboxviews.get('controlbox').el);
+
+        // Hide the controlbox so that we can see whether the test passed or failed
+        u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
+        delete _converse.connection;
+    }));
+
+    it("lets you choose a different provider",
+        mock.initConverse(
+            ['chatBoxesInitialized'],
+            { auto_login: false,
+              view_mode: 'fullscreen',
+              discover_connection_methods: false,
+              allow_registration: true },
+            async function (_converse) {
+
+        const toggle = document.querySelector(".toggle-controlbox");
+        if (!u.isVisible(document.querySelector("#controlbox"))) {
+            if (!u.isVisible(toggle)) {
+                u.removeClass('hidden', toggle);
+            }
+            toggle.click();
+        }
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.querySelector('.toggle-register-login').click();
+        const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel'));
+
+        registerview.querySelector('input[name=domain]').value = 'conversejs.org';
+        registerview.querySelector('input[type=submit]').click();
+
+        let stanza = new Strophe.Builder("stream:features", {
+                    'xmlns:stream': "http://etherx.jabber.org/streams",
+                    'xmlns': "jabber:client"
+                })
+            .c('register',  {xmlns: "http://jabber.org/features/iq-register"}).up()
+            .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
+        _converse.connection._connect_cb(mock.createRequest(stanza));
+
+        stanza = stx`
+            <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
+                <query xmlns="jabber:iq:register">
+                    <x xmlns="jabber:x:data" type="form">
+                        <instructions>Choose a username and password to register with this server</instructions>
+                        <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field>
+                        <field var="username" type="text-single" label="User"><required/></field>
+                        <field var="password" type="text-private" label="Password"><required/></field>
+                        <field var="from" type="hidden"><value>conversations.im</value></field>
+                        <field var="challenge" type="hidden"><value>15376320046808160053</value></field>
+                        <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field>
+                    </x>
+                </query>
+            </iq>`;
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 2);
+        expect(registerview.form_type).toBe('xform');
+
+        const button = await u.waitUntil(() => registerview.querySelector('.btn-secondary'));
+        expect(button.value).toBe("Choose a different provider");
+        button.click();
+
+        await u.waitUntil(() => registerview.querySelector('input[name="domain"]'));
+        expect(registerview.querySelectorAll('input[required]').length).toBe(1);
+
+        // Hide the controlbox so that we can see whether the test passed or failed
+        u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
         delete _converse.connection;
     }));
 
@@ -385,7 +497,7 @@ describe("The Registration Panel", function () {
             .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"});
         _converse.connection._connect_cb(mock.createRequest(stanza));
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <iq xmlns="jabber:client" type="result" from="conversejs.org" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en">
                 <query xmlns="jabber:iq:register">
                     <x xmlns="jabber:x:data" type="form">
@@ -408,11 +520,12 @@ describe("The Registration Panel", function () {
                           max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data>
                     <instructions>You need a client that supports x:data and CAPTCHA to register</instructions>
                 </query>
-            </iq>`);
+            </iq>`;
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
         spyOn(view, 'submitRegistrationForm').and.callThrough();
-        const username_input = view.querySelector('[name="username"]');
+
+        const username_input = await u.waitUntil(() => view.querySelector('[name="username"]'));
         username_input.value = 'romeo';
         const password_input = view.querySelector('[name="password"]');
         password_input.value = 'secret';
@@ -421,16 +534,20 @@ describe("The Registration Panel", function () {
         view.querySelector('[type="submit"]').click();
         expect(view.submitRegistrationForm).toHaveBeenCalled();
 
-        const response_IQ = u.toStanza(`
-            <iq xml:lang='en' from='conversejs.org' type='error' id='d9917b7a-588f-4ef6-8a56-0d6d3ad538ae:sendIQ'>
+        const response_IQ = stx`
+            <iq xml:lang='en' from='conversejs.org' type='error' id='d9917b7a-588f-4ef6-8a56-0d6d3ad538ae:sendIQ' xmlns="jabber:client">
                 <query xmlns='jabber:iq:register'/>
                 <error code='500' type='wait'>
                     <resource-constraint xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
                     <text xml:lang='en' xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Too many CAPTCHA requests</text>
                 </error>
-            </iq>`);
+            </iq>`;
         _converse.connection._dataRecv(mock.createRequest(response_IQ));
-        expect(view.querySelector('.error')?.textContent.trim()).toBe('Too many CAPTCHA requests');
+
+        const alert = await u.waitUntil(() => view.querySelector('.alert'));
+        expect(alert.textContent.trim()).toBe('Too many CAPTCHA requests');
+        // Hide the controlbox so that we can see whether the test passed or failed
+        u.addClass('hidden', _converse.chatboxviews.get('controlbox'));
         delete _converse.connection;
     }));
 });

+ 7 - 0
src/plugins/register/utils.js

@@ -0,0 +1,7 @@
+import { _converse, api } from '@converse/headless/core';
+
+export async function setActiveForm (value) {
+    await api.waitUntil('controlBoxInitialized');
+    const controlbox = _converse.chatboxes.get('controlbox');
+    controlbox.set({ 'active-form': value });
+}

+ 3 - 2
src/templates/form_username.js

@@ -4,12 +4,13 @@ export default  (o) => html`
     <div class="form-group">
         ${ o.label ? html`<label>${o.label}</label>` :  '' }
         <div class="input-group">
-            <div class="input-group-prepend">
                 <input name="${o.name}"
+                       class="form-control"
                        type="${o.type}"
                        value="${o.value || ''}"
                        ?required="${o.required}" />
-                <div class="input-group-text col" title="${o.domain}">${o.domain}</div>
+            <div class="input-group-append">
+                <div class="input-group-text" title="${o.domain}">${o.domain}</div>
             </div>
         </div>
     </div>`;