浏览代码

Add-hoc form fixes

- Provide actions as received in the Ad-Hoc form
- Add support for multi-stage ad-hoc forms
- Add new tests for multi-stage forms

Fixes #2240
JC Brand 2 年之前
父节点
当前提交
0fcdb2a594

+ 1 - 0
CHANGES.md

@@ -2,6 +2,7 @@
 
 
 ## 10.1.1 (Unreleased)
 ## 10.1.1 (Unreleased)
 
 
+- #2240: Ad-Hoc command result form not shown
 - #3128: Second bookmarked room shows info of the first one
 - #3128: Second bookmarked room shows info of the first one
 - Bugfix. Uyghur translations weren't loading
 - Bugfix. Uyghur translations weren't loading
 
 

+ 6 - 3
src/headless/core.js

@@ -453,13 +453,16 @@ export const api = _converse.api = {
      *  nothing to wait for, so an already resolved promise is returned.
      *  nothing to wait for, so an already resolved promise is returned.
      */
      */
     sendIQ (stanza, timeout=_converse.STANZA_TIMEOUT, reject=true) {
     sendIQ (stanza, timeout=_converse.STANZA_TIMEOUT, reject=true) {
+
+        const { connection } = _converse;
+
         let promise;
         let promise;
         stanza = stanza.tree?.() ?? stanza;
         stanza = stanza.tree?.() ?? stanza;
         if (['get', 'set'].includes(stanza.getAttribute('type'))) {
         if (['get', 'set'].includes(stanza.getAttribute('type'))) {
             timeout = timeout || _converse.STANZA_TIMEOUT;
             timeout = timeout || _converse.STANZA_TIMEOUT;
             if (reject) {
             if (reject) {
-                promise = new Promise((resolve, reject) => _converse.connection.sendIQ(stanza, resolve, reject, timeout));
-                promise.catch(e => {
+                promise = new Promise((resolve, reject) => connection.sendIQ(stanza, resolve, reject, timeout));
+                promise.catch((e) => {
                     if (e === null) {
                     if (e === null) {
                         throw new TimeoutError(
                         throw new TimeoutError(
                             `Timeout error after ${timeout}ms for the following IQ stanza: ${Strophe.serialize(stanza)}`
                             `Timeout error after ${timeout}ms for the following IQ stanza: ${Strophe.serialize(stanza)}`
@@ -467,7 +470,7 @@ export const api = _converse.api = {
                     }
                     }
                 });
                 });
             } else {
             } else {
-                promise = new Promise(resolve => _converse.connection.sendIQ(stanza, resolve, resolve, timeout));
+                promise = new Promise((resolve) => connection.sendIQ(stanza, resolve, resolve, timeout));
             }
             }
         } else {
         } else {
             _converse.connection.sendIQ(stanza);
             _converse.connection.sendIQ(stanza);

+ 89 - 7
src/headless/plugins/adhoc.js

@@ -1,10 +1,11 @@
-import { converse } from "../core.js";
 import log from "@converse/headless/log";
 import log from "@converse/headless/log";
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
+import { __ } from 'i18n';
+import { converse } from "../core.js";
 import { getAttributes } from '@converse/headless/shared/parsers';
 import { getAttributes } from '@converse/headless/shared/parsers';
 
 
-const { Strophe } = converse.env;
-let _converse, api;
+const { Strophe, u, stx, $iq } = converse.env;
+let api;
 
 
 Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands');
 Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands');
 
 
@@ -14,6 +15,18 @@ function parseForCommands (stanza) {
     return items.map(getAttributes)
     return items.map(getAttributes)
 }
 }
 
 
+function getCommandFields (iq, jid) {
+    const cmd_el = sizzle(`command[xmlns="${Strophe.NS.ADHOC}"]`, iq).pop();
+    const data = {
+        sessionid: cmd_el.getAttribute('sessionid'),
+        instructions: sizzle('x[type="form"][xmlns="jabber:x:data"] instructions', cmd_el).pop()?.textContent,
+        fields: sizzle('x[type="form"][xmlns="jabber:x:data"] field', cmd_el)
+            .map(f => u.xForm2TemplateResult(f, cmd_el, { domain: jid })),
+        actions: Array.from(cmd_el.querySelector('actions')?.children).map((a) => a.nodeName.toLowerCase()) ?? []
+    }
+    return data;
+}
+
 
 
 const adhoc_api = {
 const adhoc_api = {
     /**
     /**
@@ -30,9 +43,8 @@ const adhoc_api = {
          * @param { String } to_jid
          * @param { String } to_jid
          */
          */
         async getCommands (to_jid) {
         async getCommands (to_jid) {
-            let commands = [];
             try {
             try {
-                commands = parseForCommands(await api.disco.items(to_jid, Strophe.NS.ADHOC));
+                return parseForCommands(await api.disco.items(to_jid, Strophe.NS.ADHOC));
             } catch (e) {
             } catch (e) {
                 if (e === null) {
                 if (e === null) {
                     log.error(`Error: timeout while fetching ad-hoc commands for ${to_jid}`);
                     log.error(`Error: timeout while fetching ad-hoc commands for ${to_jid}`);
@@ -40,8 +52,78 @@ const adhoc_api = {
                     log.error(`Error while fetching ad-hoc commands for ${to_jid}`);
                     log.error(`Error while fetching ad-hoc commands for ${to_jid}`);
                     log.error(e);
                     log.error(e);
                 }
                 }
+                return [];
+            }
+        },
+
+        /**
+         * @method api.adhoc.fetchCommandForm
+         */
+        async fetchCommandForm (command) {
+            const node = command.node;
+            const jid = command.jid;
+            const stanza = $iq({
+                'type': 'set',
+                'to': jid
+            }).c('command', {
+                'xmlns': Strophe.NS.ADHOC,
+                'node': node,
+                'action': 'execute'
+            });
+            try {
+                return getCommandFields(await api.sendIQ(stanza), jid);
+
+            } catch (e) {
+                if (e === null) {
+                    log.error(`Error: timeout while trying to execute command for ${jid}`);
+                } else {
+                    log.error(`Error while trying to execute command for ${jid}`);
+                    log.error(e);
+                }
+                return {
+                    instructions: __('An error occurred while trying to fetch the command form'),
+                    fields: []
+                }
+            }
+        },
+
+        /**
+         * @method api.adhoc.runCommand
+         * @param { String } jid
+         * @param { String } sessionid
+         * @param { 'execute' | 'cancel' | 'prev' | 'next' | 'complete' } action
+         * @param { String } node
+         * @param { Array<{ string: string }> } inputs
+         */
+        async runCommand (jid, sessionid, node, action, inputs) {
+            const iq =
+                stx`<iq type="set" to="${jid}" xmlns="jabber:client">
+                    <command sessionid="${sessionid}" node="${node}" action="${action}" xmlns="${Strophe.NS.ADHOC}">
+                        <x xmlns="${Strophe.NS.XFORM}" type="submit">
+                            ${ inputs.reduce((out, { name, value }) => out + `<field var="${name}"><value>${value}</value></field>`, '') }
+                        </x>
+                    </command>
+                </iq>`;
+
+            const result = await api.sendIQ(iq, null, false);
+            if (result === null) {
+                log.warn(`A timeout occurred while trying to run an ad-hoc command`);
+                return {
+                    status: 'error',
+                    note: __('A timeout occurred'),
+                }
+            } else if (u.isErrorStanza(result)) {
+                log.error('Error while trying to execute an ad-hoc command');
+                log.error(result);
+            }
+
+            const command = result.querySelector('command');
+            const status = command?.getAttribute('status');
+            return {
+                status,
+                ...(status === 'executing' ? getCommandFields(result) : {}),
+                note: result.querySelector('note')?.textContent
             }
             }
-            return commands;
         }
         }
     }
     }
 }
 }
@@ -52,7 +134,7 @@ converse.plugins.add('converse-adhoc', {
     dependencies: ["converse-disco"],
     dependencies: ["converse-disco"],
 
 
     initialize () {
     initialize () {
-        _converse = this._converse;
+        const _converse = this._converse;
         api  = _converse.api;
         api  = _converse.api;
         Object.assign(api, adhoc_api);
         Object.assign(api, adhoc_api);
     }
     }

+ 3 - 2
src/headless/plugins/muc/api.js

@@ -1,7 +1,8 @@
 import log from '../../log';
 import log from '../../log';
-import u from '../../utils/form';
 import { Strophe } from 'strophe.js/src/strophe';
 import { Strophe } from 'strophe.js/src/strophe';
-import { _converse, api } from '../../core.js';
+import { _converse, api, converse } from '../../core.js';
+
+const { u } = converse.env;
 
 
 
 
 export default {
 export default {

+ 2 - 1
src/headless/plugins/muc/muc.js

@@ -5,7 +5,6 @@ import log from '../../log';
 import p from '../../utils/parse-helpers';
 import p from '../../utils/parse-helpers';
 import pick from 'lodash-es/pick';
 import pick from 'lodash-es/pick';
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
-import u from '../../utils/form';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
 import { _converse, api, converse } from '../../core.js';
 import { _converse, api, converse } from '../../core.js';
@@ -19,6 +18,8 @@ import { parseMUCMessage, parseMUCPresence } from './parsers.js';
 import { sendMarker } from '../../shared/actions.js';
 import { sendMarker } from '../../shared/actions.js';
 import { ROOMSTATUS } from './constants.js';
 import { ROOMSTATUS } from './constants.js';
 
 
+const { u } = converse.env;
+
 const OWNER_COMMANDS = ['owner'];
 const OWNER_COMMANDS = ['owner'];
 const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
 const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
 const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];
 const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];

+ 3 - 2
src/headless/plugins/muc/occupants.js

@@ -1,14 +1,15 @@
 import ChatRoomOccupant from './occupant.js';
 import ChatRoomOccupant from './occupant.js';
-import u from '../../utils/form';
 import { Collection } from '@converse/skeletor/src/collection.js';
 import { Collection } from '@converse/skeletor/src/collection.js';
 import { MUC_ROLE_WEIGHTS } from './constants.js';
 import { MUC_ROLE_WEIGHTS } from './constants.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe } from 'strophe.js/src/strophe.js';
 import { Strophe } from 'strophe.js/src/strophe.js';
-import { _converse, api } from '../../core.js';
+import { _converse, api, converse } from '../../core.js';
 import { getAffiliationList } from './affiliations/utils.js';
 import { getAffiliationList } from './affiliations/utils.js';
 import { getAutoFetchedAffiliationLists } from './utils.js';
 import { getAutoFetchedAffiliationLists } from './utils.js';
 import { getUniqueId } from '@converse/headless/utils/core.js';
 import { getUniqueId } from '@converse/headless/utils/core.js';
 
 
+const { u } = converse.env;
+
 
 
 /**
 /**
  * A list of {@link _converse.ChatRoomOccupant} instances, representing participants in a MUC.
  * A list of {@link _converse.ChatRoomOccupant} instances, representing participants in a MUC.

+ 5 - 1
src/headless/utils/core.js

@@ -6,7 +6,6 @@
 import DOMPurify from 'dompurify';
 import DOMPurify from 'dompurify';
 import _converse from '@converse/headless/shared/_converse.js';
 import _converse from '@converse/headless/shared/_converse.js';
 import compact from "lodash-es/compact";
 import compact from "lodash-es/compact";
-import isElement from "lodash-es/isElement";
 import isObject from "lodash-es/isObject";
 import isObject from "lodash-es/isObject";
 import last from "lodash-es/last";
 import last from "lodash-es/last";
 import log from '@converse/headless/log.js';
 import log from '@converse/headless/log.js';
@@ -17,6 +16,10 @@ import { getOpenPromise } from '@converse/openpromise';
 import { settings_api } from '@converse/headless/shared/settings/api.js';
 import { settings_api } from '@converse/headless/shared/settings/api.js';
 import { stx , toStanza } from './stanza.js';
 import { stx , toStanza } from './stanza.js';
 
 
+export function isElement (el) {
+    return el instanceof Element || el instanceof HTMLDocument;
+}
+
 export function isError (obj) {
 export function isError (obj) {
     return Object.prototype.toString.call(obj) === "[object Error]";
     return Object.prototype.toString.call(obj) === "[object Error]";
 }
 }
@@ -619,6 +622,7 @@ export function saveWindowState (ev) {
 export default Object.assign({
 export default Object.assign({
     getRandomInt,
     getRandomInt,
     getUniqueId,
     getUniqueId,
+    isElement,
     isEmptyMessage,
     isEmptyMessage,
     isValidJID,
     isValidJID,
     merge,
     merge,

+ 12 - 9
src/headless/utils/form.js

@@ -5,13 +5,17 @@
  */
  */
 import u from "./core";
 import u from "./core";
 
 
+const tpl_xform_field = (name, value) => `<field var="${name}">${ value }</field>`;
+
+const tpl_xform_value = (value) => `<value>${value}</value>`;
+
 /**
 /**
  * Takes an HTML DOM and turns it into an XForm field.
  * Takes an HTML DOM and turns it into an XForm field.
  * @private
  * @private
  * @method u#webForm2xForm
  * @method u#webForm2xForm
  * @param { DOMElement } field - the field to convert
  * @param { DOMElement } field - the field to convert
  */
  */
-u.webForm2xForm = function (field) {
+export function webForm2xForm (field) {
     const name = field.getAttribute('name');
     const name = field.getAttribute('name');
     if (!name) {
     if (!name) {
         return null; // See #1924
         return null; // See #1924
@@ -26,11 +30,10 @@ u.webForm2xForm = function (field) {
     } else {
     } else {
         value = field.value;
         value = field.value;
     }
     }
-    return u.toStanza(`
-        <field var="${name}">
-            ${ value.constructor === Array ?
-                value.map(v => `<value>${v}</value>`) :
-                `<value>${value}</value>` }
-        </field>`);
-};
-export default u;
+    return u.toStanza(tpl_xform_field(
+        name,
+        Array.isArray(value) ? value.map(tpl_xform_value) : tpl_xform_value(value),
+    ));
+}
+
+u.webForm2xForm = webForm2xForm;

+ 103 - 51
src/plugins/adhoc-views/adhoc-commands.js

@@ -1,25 +1,24 @@
 import 'shared/autocomplete/index.js';
 import 'shared/autocomplete/index.js';
-import log from "@converse/headless/log";
+import log from '@converse/headless/log';
 import tpl_adhoc from './templates/ad-hoc.js';
 import tpl_adhoc from './templates/ad-hoc.js';
 import { CustomElement } from 'shared/components/element.js';
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
-import { api, converse } from "@converse/headless/core";
-import { fetchCommandForm } from './utils.js';
+import { api, converse } from '@converse/headless/core.js';
+import { getNameAndValue } from 'utils/html.js';
 
 
-const { Strophe, $iq, sizzle, u } = converse.env;
+const { Strophe, sizzle } = converse.env;
 
 
 
 
 export default class AdHocCommands extends CustomElement {
 export default class AdHocCommands extends CustomElement {
-
     static get properties () {
     static get properties () {
         return {
         return {
             'alert': { type: String },
             'alert': { type: String },
             'alert_type': { type: String },
             'alert_type': { type: String },
-            'nonce': { type: String }, // Used to force re-rendering
-            'fetching': { type: Boolean }, // Used to force re-rendering
+            'commands': { type: Array },
+            'fetching': { type: Boolean },
             'showform': { type: String },
             'showform': { type: String },
             'view': { type: String },
             'view': { type: String },
-        }
+        };
     }
     }
 
 
     constructor () {
     constructor () {
@@ -31,12 +30,7 @@ export default class AdHocCommands extends CustomElement {
     }
     }
 
 
     render () {
     render () {
-        return tpl_adhoc(this, {
-            'hideCommandForm': ev => this.hideCommandForm(ev),
-            'runCommand': ev => this.runCommand(ev),
-            'showform': this.showform,
-            'toggleCommandForm': ev => this.toggleCommandForm(ev),
-        });
+        return tpl_adhoc(this)
     }
     }
 
 
     async fetchCommands (ev) {
     async fetchCommands (ev) {
@@ -50,7 +44,7 @@ export default class AdHocCommands extends CustomElement {
         const jid = form_data.get('jid').trim();
         const jid = form_data.get('jid').trim();
         let supported;
         let supported;
         try {
         try {
-            supported = await api.disco.supports(Strophe.NS.ADHOC, jid)
+            supported = await api.disco.supports(Strophe.NS.ADHOC, jid);
         } catch (e) {
         } catch (e) {
             log.error(e);
             log.error(e);
         } finally {
         } finally {
@@ -79,59 +73,117 @@ export default class AdHocCommands extends CustomElement {
         ev.preventDefault();
         ev.preventDefault();
         const node = ev.target.getAttribute('data-command-node');
         const node = ev.target.getAttribute('data-command-node');
         const cmd = this.commands.filter(c => c.node === node)[0];
         const cmd = this.commands.filter(c => c.node === node)[0];
-        this.showform !== node && await fetchCommandForm(cmd);
-        this.showform = node;
+        if (this.showform === node) {
+            this.showform = '';
+            this.requestUpdate();
+        } else {
+            const form = await api.adhoc.fetchCommandForm(cmd);
+            cmd.sessionid = form.sessionid;
+            cmd.instructions = form.instructions;
+            cmd.fields = form.fields;
+            cmd.actions = form.actions;
+            this.showform = node;
+        }
     }
     }
 
 
-    hideCommandForm (ev) {
+    executeAction (ev) {
         ev.preventDefault();
         ev.preventDefault();
-        this.nonce = u.getUniqueId();
-        this.showform = ''
+
+        const action = ev.target.getAttribute('data-action');
+
+        if (['execute', 'next', 'prev', 'complete'].includes(action)) {
+            this.runCommand(ev.target.form, action);
+        } else {
+            log.error(`Unknown action: ${action}`);
+        }
     }
     }
 
 
-    async runCommand (ev) {
-        ev.preventDefault();
-        const form_data = new FormData(ev.target);
+    clearCommand (cmd) {
+        delete cmd.alert;
+        delete cmd.instructions;
+        delete cmd.sessionid;
+        delete cmd.alert_type;
+        cmd.fields = [];
+        cmd.acions = [];
+        this.showform = '';
+    }
+
+    async runCommand (form, action) {
+        const form_data = new FormData(form);
         const jid = form_data.get('command_jid').trim();
         const jid = form_data.get('command_jid').trim();
         const node = form_data.get('command_node').trim();
         const node = form_data.get('command_node').trim();
 
 
         const cmd = this.commands.filter(c => c.node === node)[0];
         const cmd = this.commands.filter(c => c.node === node)[0];
-        cmd.alert = null;
-        this.nonce = u.getUniqueId();
-
-        const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
-        const config_array = inputs
-            .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
-            .map(u.webForm2xForm)
-            .filter(n => n);
-
-        const iq = $iq({to: jid, type: "set"})
-            .c("command", {
-                'sessionid': cmd.sessionid,
-                'node': cmd.node,
-                'xmlns': Strophe.NS.ADHOC
-            }).c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
-        config_array.forEach(node => iq.cnode(node).up());
-
-        let result;
-        try {
-            result = await api.sendIQ(iq);
-        } catch (e) {
+        delete cmd.alert;
+        this.requestUpdate();
+
+        const inputs = action === 'prev' ? [] :
+            sizzle(':input:not([type=button]):not([type=submit])', form)
+                .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
+                .map(getNameAndValue)
+                .filter(n => n);
+
+        const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs);
+
+        const { fields, status, note, instructions, actions } = response;
+
+        if (status === 'error') {
             cmd.alert_type = 'danger';
             cmd.alert_type = 'danger';
             cmd.alert = __(
             cmd.alert = __(
                 'Sorry, an error occurred while trying to execute the command. See the developer console for details'
                 'Sorry, an error occurred while trying to execute the command. See the developer console for details'
             );
             );
-            log.error('Error while trying to execute an ad-hoc command');
-            log.error(e);
+            return this.requestUpdate();
         }
         }
 
 
-        if (result) {
-            cmd.alert = result.querySelector('note')?.textContent;
+        if (status === 'executing') {
+            cmd.alert = __('Executing');
+            cmd.fields = fields;
+            cmd.instructions = instructions;
+            cmd.alert_type = 'primary';
+            cmd.actions = actions;
+        } else if (status === 'completed') {
+            this.alert_type = 'primary';
+            this.alert = __('Completed');
+            this.note = note;
+            this.clearCommand(cmd);
+        } else {
+            log.error(`Unexpected status for ad-hoc command: ${status}`);
+            cmd.alert = __('Completed');
+            cmd.alert_type = 'primary';
+        }
+        this.requestUpdate();
+    }
+
+    async cancel (ev) {
+        ev.preventDefault();
+        this.showform = '';
+        this.requestUpdate();
+
+        const form_data = new FormData(ev.target.form);
+        const jid = form_data.get('command_jid').trim();
+        const node = form_data.get('command_node').trim();
+
+        const cmd = this.commands.filter(c => c.node === node)[0];
+        delete cmd.alert;
+        this.requestUpdate();
+
+        const { status } = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, 'cancel', []);
+
+        if (status === 'error') {
+            cmd.alert_type = 'danger';
+            cmd.alert = __(
+                'An error occurred while trying to cancel the command. See the developer console for details'
+            );
+        } else if (status === 'canceled') {
+            this.alert_type = '';
+            this.alert = '';
+            this.clearCommand(cmd);
         } else {
         } else {
-            cmd.alert = 'Done';
+            log.error(`Unexpected status for ad-hoc command: ${status}`);
+            cmd.alert = __('Error: unexpected result');
+            cmd.alert_type = 'danger';
         }
         }
-        cmd.alert_type = 'primary';
-        this.nonce = u.getUniqueId();
+        this.requestUpdate();
     }
     }
 }
 }
 
 

+ 23 - 7
src/plugins/adhoc-views/templates/ad-hoc-command-form.js

@@ -1,25 +1,41 @@
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { html } from "lit";
 import { html } from "lit";
 
 
-export default (o, command) => {
-    const i18n_hide = __('Hide');
-    const i18n_run = __('Execute');
+
+const action_map = {
+    execute: __('Execute'),
+    prev: __('Previous'),
+    next: __('Next'),
+    complete: __('Complete'),
+}
+
+export default (el, command) => {
+    const i18n_cancel = __('Cancel');
+
     return html`
     return html`
         <span> <!-- Don't remove this <span>,
         <span> <!-- Don't remove this <span>,
                     this is a workaround for a lit bug where a <form> cannot be removed
                     this is a workaround for a lit bug where a <form> cannot be removed
                     if it contains an <input> with name "remove" -->
                     if it contains an <input> with name "remove" -->
-        <form @submit=${o.runCommand}>
+            <form>
             ${ command.alert ? html`<div class="alert alert-${command.alert_type}" role="alert">${command.alert}</div>` : '' }
             ${ command.alert ? html`<div class="alert alert-${command.alert_type}" role="alert">${command.alert}</div>` : '' }
             <fieldset class="form-group">
             <fieldset class="form-group">
                 <input type="hidden" name="command_node" value="${command.node}"/>
                 <input type="hidden" name="command_node" value="${command.node}"/>
                 <input type="hidden" name="command_jid" value="${command.jid}"/>
                 <input type="hidden" name="command_jid" value="${command.jid}"/>
 
 
-                <p class="form-help">${command.instructions}</p>
+                <p class="form-instructions">${command.instructions}</p>
                 ${ command.fields }
                 ${ command.fields }
             </fieldset>
             </fieldset>
             <fieldset>
             <fieldset>
-                <input type="submit" class="btn btn-primary" value="${i18n_run}">
-                <input type="button" class="btn btn-secondary button-cancel" value="${i18n_hide}" @click=${o.hideCommandForm}>
+                ${ command.actions.map((action) =>
+                    html`<input data-action="${action}"
+                        @click=${(ev) => el.executeAction(ev)}
+                        type="button"
+                        class="btn btn-primary"
+                        value="${action_map[action]}">`)
+                 }<input type="button"
+                       class="btn btn-secondary button-cancel"
+                       value="${i18n_cancel}"
+                       @click=${(ev) => el.cancel(ev)}>
             </fieldset>
             </fieldset>
         </form>
         </form>
         </span>
         </span>

+ 3 - 3
src/plugins/adhoc-views/templates/ad-hoc-command.js

@@ -1,17 +1,17 @@
 import { html } from "lit";
 import { html } from "lit";
 import tpl_command_form from './ad-hoc-command-form.js';
 import tpl_command_form from './ad-hoc-command-form.js';
 
 
-export default (o, command) => html`
+export default (el, command) => html`
     <li class="room-item list-group-item">
     <li class="room-item list-group-item">
         <div class="available-chatroom d-flex flex-row">
         <div class="available-chatroom d-flex flex-row">
             <a class="open-room available-room w-100"
             <a class="open-room available-room w-100"
-               @click=${o.toggleCommandForm}
+               @click=${(ev) => el.toggleCommandForm(ev)}
                data-command-node="${command.node}"
                data-command-node="${command.node}"
                data-command-jid="${command.jid}"
                data-command-jid="${command.jid}"
                data-command-name="${command.name}"
                data-command-name="${command.name}"
                title="${command.name}"
                title="${command.name}"
                href="#">${command.name || command.jid}</a>
                href="#">${command.name || command.jid}</a>
         </div>
         </div>
-        ${ command.node === o.showform ? tpl_command_form(o, command) : '' }
+        ${ command.node === el.showform ? tpl_command_form(el, command) : '' }
     </li>
     </li>
 `;
 `;

+ 5 - 2
src/plugins/adhoc-views/templates/ad-hoc.js

@@ -5,7 +5,7 @@ import { getAutoCompleteList } from 'plugins/muc-views/utils.js';
 import { html } from "lit";
 import { html } from "lit";
 
 
 
 
-export default (el, o) => {
+export default (el) => {
     const i18n_choose_service = __('On which entity do you want to run commands?');
     const i18n_choose_service = __('On which entity do you want to run commands?');
     const i18n_choose_service_instructions = __(
     const i18n_choose_service_instructions = __(
         'Certain XMPP services and entities allow privileged users to execute ad-hoc commands on them.');
         'Certain XMPP services and entities allow privileged users to execute ad-hoc commands on them.');
@@ -15,6 +15,8 @@ export default (el, o) => {
     const i18n_no_commands_found = __('No commands found');
     const i18n_no_commands_found = __('No commands found');
     return html`
     return html`
         ${ el.alert ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert}</div>` : '' }
         ${ el.alert ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert}</div>` : '' }
+        ${ el.note ? html`<p class="form-help">${el.note}</p>` : '' }
+
         <form class="converse-form" @submit=${el.fetchCommands}>
         <form class="converse-form" @submit=${el.fetchCommands}>
             <fieldset class="form-group">
             <fieldset class="form-group">
                 <label>
                 <label>
@@ -22,6 +24,7 @@ export default (el, o) => {
                     <p class="form-help">${i18n_choose_service_instructions}</p>
                     <p class="form-help">${i18n_choose_service_instructions}</p>
                     <converse-autocomplete
                     <converse-autocomplete
                         .getAutoCompleteList="${getAutoCompleteList}"
                         .getAutoCompleteList="${getAutoCompleteList}"
+                        required
                         placeholder="${i18n_jid_placeholder}"
                         placeholder="${i18n_jid_placeholder}"
                         name="jid">
                         name="jid">
                     </converse-autocomplete>
                     </converse-autocomplete>
@@ -34,7 +37,7 @@ export default (el, o) => {
             <fieldset class="form-group">
             <fieldset class="form-group">
                 <ul class="list-group">
                 <ul class="list-group">
                     <li class="list-group-item active">${ el.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li>
                     <li class="list-group-item active">${ el.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li>
-                    ${ el.commands.map(cmd => tpl_command(o, cmd)) }
+                    ${ el.commands.map(cmd => tpl_command(el, cmd)) }
                 </ul>
                 </ul>
             </fieldset>`
             </fieldset>`
             : '' }
             : '' }

+ 438 - 9
src/plugins/adhoc-views/tests/adhoc.js

@@ -16,11 +16,8 @@ describe("Ad-hoc commands", function () {
         const adhoc_form = modal.querySelector('converse-adhoc-commands');
         const adhoc_form = modal.querySelector('converse-adhoc-commands');
         await u.waitUntil(() => u.isVisible(adhoc_form));
         await u.waitUntil(() => u.isVisible(adhoc_form));
 
 
-        const input = adhoc_form.querySelector('input[name="jid"]');
-        input.value = entity_jid;
-
-        const submit = adhoc_form.querySelector('input[type="submit"]');
-        submit.click();
+        adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+        adhoc_form.querySelector('input[type="submit"]').click();
 
 
         await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
         await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
 
 
@@ -31,7 +28,8 @@ describe("Ad-hoc commands", function () {
             <iq type="result"
             <iq type="result"
                 id="${iq.getAttribute("id")}"
                 id="${iq.getAttribute("id")}"
                 to="${_converse.jid}"
                 to="${_converse.jid}"
-                from="${entity_jid}">
+                from="${entity_jid}"
+                xmlns="jabber:client">
             <query xmlns="http://jabber.org/protocol/disco#items"
             <query xmlns="http://jabber.org/protocol/disco#items"
                     node="http://jabber.org/protocol/commands">
                     node="http://jabber.org/protocol/commands">
                 <item jid="${entity_jid}"
                 <item jid="${entity_jid}"
@@ -125,12 +123,443 @@ describe("Ad-hoc commands", function () {
         expect(inputs[4].getAttribute('name')).toBe('password');
         expect(inputs[4].getAttribute('name')).toBe('password');
         expect(inputs[4].getAttribute('type')).toBe('password');
         expect(inputs[4].getAttribute('type')).toBe('password');
         expect(inputs[4].getAttribute('value')).toBe('secret');
         expect(inputs[4].getAttribute('value')).toBe('secret');
-        expect(inputs[5].getAttribute('type')).toBe('submit');
-        expect(inputs[5].getAttribute('value')).toBe('Execute');
+        expect(inputs[5].getAttribute('type')).toBe('button');
+        expect(inputs[5].getAttribute('value')).toBe('Complete');
         expect(inputs[6].getAttribute('type')).toBe('button');
         expect(inputs[6].getAttribute('type')).toBe('button');
-        expect(inputs[6].getAttribute('value')).toBe('Hide');
+        expect(inputs[6].getAttribute('value')).toBe('Cancel');
 
 
         inputs[6].click();
         inputs[6].click();
         await u.waitUntil(() => !u.isVisible(form));
         await u.waitUntil(() => !u.isVisible(form));
     }));
     }));
 });
 });
+
+describe("Ad-hoc commands consisting of multiple steps", function () {
+
+    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    it("can be queried and executed via a modal", mock.initConverse([], {}, async (_converse) => {
+        const { api } = _converse;
+        const entity_jid = 'montague.lit';
+        const { IQ_stanzas } = _converse.connection;
+
+        const modal = await api.modal.show('converse-user-settings-modal');
+        await u.waitUntil(() => u.isVisible(modal));
+        modal.querySelector('#commands-tab').click();
+
+        const adhoc_form = modal.querySelector('converse-adhoc-commands');
+        await u.waitUntil(() => u.isVisible(adhoc_form));
+
+        adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+        adhoc_form.querySelector('input[type="submit"]').click();
+
+        await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+        let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+        let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq from="${_converse.jid}" id="${iq.getAttribute('id')}" to="${entity_jid}" type="get" xmlns="jabber:client">
+                <query node="http://jabber.org/protocol/commands" xmlns="http://jabber.org/protocol/disco#items"/>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+                    <item node="uptime" name="Get uptime" jid="${entity_jid}"/>
+                    <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/>
+                    <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/>
+                    <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/>
+                </query>
+            </iq>
+        `));
+
+        const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+        item.click();
+
+        sel = `iq[to="${entity_jid}"] command`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client">
+                <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/>
+            </iq>`
+        );
+
+        const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad";
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+        <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+            <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                <actions>
+                    <next/>
+                    <complete/>
+                </actions>
+
+                <x xmlns="jabber:x:data" type="form">
+                    <title>Step 1</title>
+                    <instructions>Here's a form.</instructions>
+                    <field label="text-private-label" type="text-private" var="text-private-field">
+                        <value>text-private-value</value>
+                    </field>
+                    <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+                        <value>jid@multi/value#1</value>
+                        <value>jid@multi/value#2</value>
+                    </field>
+                    <field label="text-multi-label" type="text-multi" var="text-multi-field">
+                        <value>text</value>
+                        <value>multi</value>
+                        <value>value</value>
+                    </field>
+                    <field label="jid-single-label" type="jid-single" var="jid-single-field">
+                        <value>jid@single/value</value>
+                    </field>
+                    <field label="list-single-label" type="list-single" var="list-single-field">
+                        <option label="list-single-value"><value>list-single-value</value></option>
+                        <option label="list-single-value#2"><value>list-single-value#2</value></option>
+                        <option label="list-single-value#3"><value>list-single-value#3</value></option>
+                        <value>list-single-value</value>
+                    </field>
+                </x>
+            </command>
+        </iq>
+        `));
+
+        let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]'));
+        button.click();
+
+        sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+                <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands">
+                    <x type="submit" xmlns="jabber:x:data">
+                        <field var="text-private-field">
+                            <value>text-private-value</value>
+                        </field>
+                        <field var="jid-multi-field">
+                            <value>jid@multi/value#1</value>
+                        </field>
+                        <field var="text-multi-field">
+                            <value>text</value>
+                        </field>
+                        <field var="jid-single-field">
+                            <value>jid@single/value</value>
+                        </field>
+                        <field var="list-single-field">
+                            <value>list-single-value</value>
+                        </field>
+                    </x>
+                </command>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                <actions>
+                    <prev/>
+                    <next/>
+                    <complete/>
+                </actions>
+                <x xmlns="jabber:x:data" type="form">
+                    <title>Step 2</title>
+                    <instructions>Here's another form.</instructions>
+                    <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+                        <value>jid@multi/value#1</value>
+                        <value>jid@multi/value#2</value>
+                    </field>
+                    <field label="boolean-label" type="boolean" var="boolean-field">
+                        <value>1</value>
+                    </field>
+                    <field label="fixed-label" type="fixed" var="fixed-field#1">
+                        <value>fixed-value</value>
+                    </field>
+                    <field label="list-single-label" type="list-single" var="list-single-field">
+                        <option label="list-single-value">
+                            <value>list-single-value</value>
+                        </option>
+                        <option label="list-single-value#2">
+                            <value>list-single-value#2</value>
+                        </option>
+                        <option label="list-single-value#3">
+                            <value>list-single-value#3</value>
+                        </option>
+                        <value>list-single-value</value>
+                    </field>
+                    <field label="text-single-label" type="text-single" var="text-single-field">
+                        <value>text-single-value</value>
+                    </field>
+                </x>
+                </command>
+            </iq>
+        `));
+
+        button = await u.waitUntil(() => modal.querySelector('input[data-action="complete"]'));
+        button.click();
+
+        sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="complete"]`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq xmlns="jabber:client"
+                type="set"
+                to="${entity_jid}"
+                id="${iq.getAttribute('id')}">
+
+                <command xmlns="http://jabber.org/protocol/commands"
+                        sessionid="${sessionid}"
+                        node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+                        action="complete">
+                    <x xmlns="jabber:x:data"
+                    type="submit">
+                    <field var="text-private-field">
+                    <value>text-private-value</value></field>
+                    <field var="jid-multi-field"><value>jid@multi/value#1</value></field>
+                    <field var="text-multi-field"><value>text</value></field>
+                    <field var="jid-single-field"><value>jid@single/value</value></field>
+                    <field var="list-single-field"><value>list-single-value</value></field>
+                    </x>
+                </command>
+            </iq>`
+        );
+
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:server" type="result" from="${entity_jid}" to="${_converse.jid}" id="${iq.getAttribute("id")}">
+                <command xmlns="http://jabber.org/protocol/commands"
+                        sessionid="${sessionid}"
+                        node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+                        status="completed">
+                    <note type="info">Service has been configured.</note>
+                </command>
+            </iq>`)
+        );
+    }));
+
+    it("can be canceled", mock.initConverse([], {}, async (_converse) => {
+        const { api } = _converse;
+        const entity_jid = 'montague.lit';
+        const { IQ_stanzas } = _converse.connection;
+
+        const modal = await api.modal.show('converse-user-settings-modal');
+        await u.waitUntil(() => u.isVisible(modal));
+        modal.querySelector('#commands-tab').click();
+
+        const adhoc_form = modal.querySelector('converse-adhoc-commands');
+        await u.waitUntil(() => u.isVisible(adhoc_form));
+
+        adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+        adhoc_form.querySelector('input[type="submit"]').click();
+
+        await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+        let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+        let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+                    <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command" jid="${entity_jid}"/>
+                </query>
+            </iq>
+        `));
+
+        const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+        item.click();
+
+        sel = `iq[to="${entity_jid}"] command`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99cc";
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+        <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+            <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                <actions>
+                    <next/>
+                    <complete/>
+                </actions>
+
+                <x xmlns="jabber:x:data" type="form">
+                    <title>Step 1</title>
+                    <instructions>Here's a form.</instructions>
+                    <field label="text-private-label" type="text-private" var="text-private-field">
+                        <value>text-private-value</value>
+                    </field>
+                </x>
+            </command>
+        </iq>
+        `));
+
+        const button = await u.waitUntil(() => modal.querySelector('input.button-cancel'));
+        button.click();
+
+        sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+                <command sessionid="${sessionid}"
+                        node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+                        action="cancel"
+                        xmlns="http://jabber.org/protocol/commands">
+                </command>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <command xmlns="http://jabber.org/protocol/commands"
+                        sessionid="${sessionid}"
+                        status="canceled"
+                        node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                </command>
+            </iq>
+        `));
+    }));
+
+    it("can be navigated backwards", mock.initConverse([], {}, async (_converse) => {
+        const { api } = _converse;
+        const entity_jid = 'montague.lit';
+        const { IQ_stanzas } = _converse.connection;
+
+        const modal = await api.modal.show('converse-user-settings-modal');
+        await u.waitUntil(() => u.isVisible(modal));
+        modal.querySelector('#commands-tab').click();
+
+        const adhoc_form = modal.querySelector('converse-adhoc-commands');
+        await u.waitUntil(() => u.isVisible(adhoc_form));
+
+        adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
+        adhoc_form.querySelector('input[type="submit"]').click();
+
+        await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info');
+
+        let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`;
+        let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq from="${_converse.jid}" to="${entity_jid}" type="get" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+                <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"/>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands">
+                    <item node="uptime" name="Get uptime" jid="${entity_jid}"/>
+                    <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/>
+                    <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/>
+                    <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/>
+                </query>
+            </iq>
+        `));
+
+        const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]'));
+        item.click();
+
+        sel = `iq[to="${entity_jid}"] command`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client">
+                <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/>
+            </iq>`);
+
+        const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad";
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                    <actions>
+                        <next/>
+                        <complete/>
+                    </actions>
+
+                    <x xmlns="jabber:x:data" type="form">
+                        <title>Step 1</title>
+                        <instructions>Here's a form.</instructions>
+                        <field label="text-private-label" type="text-private" var="text-private-field">
+                            <value>text-private-value</value>
+                        </field>
+                    </x>
+                </command>
+            </iq>
+        `));
+
+        let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]'));
+        button.click();
+
+        sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+                <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands">
+                    <x type="submit" xmlns="jabber:x:data">
+                        <field var="text-private-field">
+                            <value>text-private-value</value>
+                        </field>
+                    </x>
+                </command>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                <actions>
+                    <prev/>
+                    <next/>
+                    <complete/>
+                </actions>
+                <x xmlns="jabber:x:data" type="form">
+                    <title>Step 2</title>
+                    <instructions>Here's another form.</instructions>
+                    <field label="jid-multi-label" type="jid-multi" var="jid-multi-field">
+                        <value>jid@multi/value#1</value>
+                        <value>jid@multi/value#2</value>
+                    </field>
+                </x>
+                </command>
+            </iq>
+        `));
+
+        button = await u.waitUntil(() => modal.querySelector('input[data-action="prev"]'));
+        button.click();
+
+        sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="prev"]`;
+        iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop());
+
+        expect(iq).toEqualStanza(stx`
+            <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}">
+                <command sessionid="${sessionid}"
+                        node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"
+                        action="prev"
+                        xmlns="http://jabber.org/protocol/commands">
+                </command>
+            </iq>`
+        );
+
+        _converse.connection._dataRecv(mock.createRequest(stx`
+            <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}">
+                <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi">
+                    <actions>
+                        <next/>
+                        <complete/>
+                    </actions>
+
+                    <x xmlns="jabber:x:data" type="form">
+                        <title>Step 1</title>
+                        <instructions>Here's a form.</instructions>
+                        <field label="text-private-label" type="text-private" var="text-private-field">
+                            <value>text-private-value</value>
+                        </field>
+                    </x>
+                </command>
+            </iq>
+        `));
+    }));
+});

+ 0 - 34
src/plugins/adhoc-views/utils.js

@@ -1,34 +0,0 @@
-import log from "@converse/headless/log";
-import { api, converse } from "@converse/headless/core";
-
-const { Strophe, $iq, sizzle, u } = converse.env;
-
-export async function fetchCommandForm (command) {
-    const node = command.node;
-    const jid = command.jid;
-    const stanza = $iq({
-        'type': 'set',
-        'to': jid
-    }).c('command', {
-        'xmlns': Strophe.NS.ADHOC,
-        'node': node,
-        'action': 'execute'
-    });
-    try {
-        const iq = await api.sendIQ(stanza);
-        const cmd_el = sizzle(`command[xmlns="${Strophe.NS.ADHOC}"]`, iq).pop();
-        command.sessionid = cmd_el.getAttribute('sessionid');
-        command.instructions = sizzle('x[type="form"][xmlns="jabber:x:data"] instructions', cmd_el).pop()?.textContent;
-        command.fields = sizzle('x[type="form"][xmlns="jabber:x:data"] field', cmd_el)
-            .map(f => u.xForm2TemplateResult(f, cmd_el, { domain: jid }));
-
-    } catch (e) {
-        if (e === null) {
-            log.error(`Error: timeout while trying to execute command for ${jid}`);
-        } else {
-            log.error(`Error while trying to execute command for ${jid}`);
-            log.error(e);
-        }
-        command.fields = [];
-    }
-}

+ 3 - 3
src/plugins/register/panel.js

@@ -5,7 +5,7 @@ import tpl_form_url from "templates/form_url.js";
 import tpl_form_username from "templates/form_username.js";
 import tpl_form_username from "templates/form_username.js";
 import tpl_register_panel from "./templates/register_panel.js";
 import tpl_register_panel from "./templates/register_panel.js";
 import tpl_spinner from "templates/spinner.js";
 import tpl_spinner from "templates/spinner.js";
-import utils from "@converse/headless/utils/form";
+import { webForm2xForm } from "@converse/headless/utils/form";
 import { ElementView } from "@converse/skeletor/src/element";
 import { ElementView } from "@converse/skeletor/src/element";
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core.js";
 import { _converse, api, converse } from "@converse/headless/core.js";
@@ -318,7 +318,7 @@ class RegisterPanel extends ElementView {
     getFormFields (stanza) {
     getFormFields (stanza) {
         if (this.form_type === 'xform') {
         if (this.form_type === 'xform') {
             return Array.from(stanza.querySelectorAll('field')).map(field =>
             return Array.from(stanza.querySelectorAll('field')).map(field =>
-                utils.xForm2TemplateResult(field, stanza, {'domain': this.domain})
+                u.xForm2TemplateResult(field, stanza, {'domain': this.domain})
             );
             );
         } else {
         } else {
             return this.getLegacyFormFields();
             return this.getLegacyFormFields();
@@ -420,7 +420,7 @@ class RegisterPanel extends ElementView {
         if (this.form_type === 'xform') {
         if (this.form_type === 'xform') {
             iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
             iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
 
 
-            const xml_nodes = inputs.map(i => utils.webForm2xForm(i)).filter(n => n);
+            const xml_nodes = inputs.map(i => webForm2xForm(i)).filter(n => n);
             xml_nodes.forEach(n => iq.cnode(n).up());
             xml_nodes.forEach(n => iq.cnode(n).up());
         } else {
         } else {
             inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));
             inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));

+ 13 - 9
src/shared/styles/forms.scss

@@ -9,14 +9,26 @@
     }
     }
 
 
     form {
     form {
+
+        label {
+            font-weight: bold;
+        }
+
+        .form-instructions {
+            color: var(--text-color);
+            margin-bottom: 1em;
+        }
+
         .hidden-username {
         .hidden-username {
             opacity: 0 !important;
             opacity: 0 !important;
             height: 0 !important;
             height: 0 !important;
             padding: 0 !important;
             padding: 0 !important;
         }
         }
+
         .error-feedback {
         .error-feedback {
             margin-bottom: 0.5em;
             margin-bottom: 0.5em;
         }
         }
+
         .form-check-label {
         .form-check-label {
             margin-top: $form-check-input-margin-y;
             margin-top: $form-check-input-margin-y;
         }
         }
@@ -101,17 +113,9 @@
             input[type=text] {
             input[type=text] {
                 min-width: 50%;
                 min-width: 50%;
             }
             }
-            input[type=text],
-            input[type=password],
-            input[type=number],
-            input[type=button],
-            input[type=submit] {
-                padding: 0.5em;
-            }
             input[type=button],
             input[type=button],
             input[type=submit] {
             input[type=submit] {
-                padding-left: 1em;
-                padding-right: 1em;
+                margin-right: 0.25em;
                 border: none;
                 border: none;
             }
             }
             input.error {
             input.error {

+ 14 - 1
src/shared/tests/mock.js

@@ -4,8 +4,21 @@ const converse = window.converse;
 converse.load();
 converse.load();
 const { u, sizzle, Strophe, dayjs, $iq, $msg, $pres } = converse.env;
 const { u, sizzle, Strophe, dayjs, $iq, $msg, $pres } = converse.env;
 
 
+
 jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000;
 jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000;
 
 
+jasmine.toEqualStanza = function toEqualStanza () {
+    return {
+        compare (actual, expected) {
+            const result = { pass: u.isEqualNode(actual, expected) };
+            if (!result.pass) {
+                result.message = `Stanzas don't match:\nActual:\n${actual.outerHTML}\nExpected:\n${expected.outerHTML}`;
+            }
+            return result;
+        }
+    }
+}
+
 function initConverse (promise_names=[], settings=null, func) {
 function initConverse (promise_names=[], settings=null, func) {
     if (typeof promise_names === "function") {
     if (typeof promise_names === "function") {
         func = promise_names;
         func = promise_names;
@@ -670,7 +683,7 @@ async function _initConverse (settings) {
             name[last] = name[last].charAt(0).toUpperCase()+name[last].slice(1);
             name[last] = name[last].charAt(0).toUpperCase()+name[last].slice(1);
             fullname = name.join(' ');
             fullname = name.join(' ');
         }
         }
-        const vcard = $iq().c('vCard').c('FN').t(fullname).nodeTree;
+        const vcard = $iq().c('vCard').c('FN').t(fullname).tree();
         return {
         return {
             'stanza': vcard,
             'stanza': vcard,
             'fullname': vcard.querySelector('FN')?.textContent,
             'fullname': vcard.querySelector('FN')?.textContent,

+ 10 - 4
src/templates/form_textarea.js

@@ -1,6 +1,12 @@
 import { html } from "lit";
 import { html } from "lit";
+import u from '@converse/headless/utils/core.js';
 
 
-export default  (o) => html`
-    <label class="label-ta">${o.label}</label>
-    <textarea name="${o.name}">${o.value}</textarea>
-`;
+export default  (o) => {
+    const id = u.getUniqueId();
+    return html`
+        <div class="form-group">
+            <label class="label-ta" for="${id}">${o.label}</label>
+            <textarea name="${o.name}" id="${id}" class="form-control">${o.value}</textarea>
+        </div>
+    `;
+};

+ 89 - 2
src/utils/html.js

@@ -22,7 +22,7 @@ import { converse } from '@converse/headless/core';
 import { getURI, isAudioURL, isImageURL, isVideoURL } from '@converse/headless/utils/url.js';
 import { getURI, isAudioURL, isImageURL, isVideoURL } from '@converse/headless/utils/url.js';
 import { render } from 'lit';
 import { render } from 'lit';
 
 
-const { sizzle } = converse.env;
+const { sizzle, Strophe } = converse.env;
 
 
 const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
 const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
 
 
@@ -54,6 +54,93 @@ const XFORM_VALIDATE_TYPE_MAP = {
     'xs:time': 'time',
     'xs:time': 'time',
 }
 }
 
 
+
+const EMPTY_TEXT_REGEX = /\s*\n\s*/
+
+function stripEmptyTextNodes (el) {
+    el = el.tree?.() ?? el;
+
+    let n;
+    const text_nodes = [];
+    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, (node) => {
+        if (node.parentElement.nodeName.toLowerCase() === 'body') {
+            return NodeFilter.FILTER_REJECT;
+        }
+        return NodeFilter.FILTER_ACCEPT;
+    });
+    while (n = walker.nextNode()) text_nodes.push(n);
+    text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(n.data) && n.parentElement.removeChild(n))
+
+    return el;
+}
+
+const serializer = new XMLSerializer();
+
+/**
+ * Given two XML or HTML elements, determine if they're equal
+ * @param { XMLElement | HTMLElement } actual
+ * @param { XMLElement | HTMLElement } expected
+ * @returns { Boolean }
+ */
+function isEqualNode (actual, expected) {
+    if (!u.isElement(actual)) throw new Error("Element being compared must be an Element!");
+
+    actual = stripEmptyTextNodes(actual);
+    expected = stripEmptyTextNodes(expected);
+
+    let isEqual = actual.isEqualNode(expected);
+
+    if (!isEqual) {
+        // XXX: This is a hack.
+        // When creating two XML elements, one via DOMParser, and one via
+        // createElementNS (or createElement), then "isEqualNode" doesn't match.
+        //
+        // For example, in the following code `isEqual` is false:
+        // ------------------------------------------------------
+        // const a = document.createElementNS('foo', 'div');
+        // a.setAttribute('xmlns', 'foo');
+        //
+        // const b = (new DOMParser()).parseFromString('<div xmlns="foo"></div>', 'text/xml').firstElementChild;
+        // const isEqual = a.isEqualNode(div); //  false
+        //
+        // The workaround here is to serialize both elements to string and then use
+        // DOMParser again for both (via xmlHtmlNode).
+        //
+        // This is not efficient, but currently this is only being used in tests.
+        //
+        const { xmlHtmlNode } = Strophe;
+        const actual_string = serializer.serializeToString(actual);
+        const expected_string = serializer.serializeToString(expected);
+        isEqual = actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string));
+    }
+
+    return isEqual;
+}
+
+/**
+ * Given an HTMLElement representing a form field, return it's name and value.
+ * @param { HTMLElement } field
+ * @returns { { string, string } | null }
+ */
+export function getNameAndValue(field) {
+    const name = field.getAttribute('name');
+    if (!name) {
+        return null; // See #1924
+    }
+    let value;
+    if (field.getAttribute('type') === 'checkbox') {
+        value = field.checked && 1 || 0;
+    } else if (field.tagName == "TEXTAREA") {
+        value = field.value.split('\n').filter(s => s.trim());
+    } else if (field.tagName == "SELECT") {
+        value = u.getSelectValues(field);
+    } else {
+        value = field.value;
+    }
+    return { name, value };
+}
+
+
 function getInputType(field) {
 function getInputType(field) {
     const type = XFORM_TYPE_MAP[field.getAttribute('type')]
     const type = XFORM_TYPE_MAP[field.getAttribute('type')]
     if (type == 'text') {
     if (type == 'text') {
@@ -525,6 +612,6 @@ u.xForm2TemplateResult = function (field, stanza, options={}) {
     }
     }
 };
 };
 
 
-Object.assign(u, { getOOBURLMarkup, ancestor, slideIn, slideOut });
+Object.assign(u, { getOOBURLMarkup, ancestor, slideIn, slideOut, isEqualNode });
 
 
 export default u;
 export default u;

+ 1 - 0
webpack.html

@@ -21,6 +21,7 @@
             }
             }
         });
         });
         converse.initialize({
         converse.initialize({
+            reuse_scram_keys: true,
             muc_subscribe_to_rai: true,
             muc_subscribe_to_rai: true,
             theme: 'dracula',
             theme: 'dracula',
             show_send_button: true,
             show_send_button: true,