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
This commit is contained in:
JC Brand 2023-01-18 15:08:34 +01:00
parent 5029d93523
commit 0fcdb2a594
20 changed files with 820 additions and 147 deletions

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { converse } from "../core.js";
import log from "@converse/headless/log";
import sizzle from 'sizzle';
import { __ } from 'i18n';
import { converse } from "../core.js";
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');
@ -14,6 +15,18 @@ function parseForCommands (stanza) {
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 = {
/**
@ -30,9 +43,8 @@ const adhoc_api = {
* @param { String } to_jid
*/
async getCommands (to_jid) {
let commands = [];
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) {
if (e === null) {
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(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"],
initialize () {
_converse = this._converse;
const _converse = this._converse;
api = _converse.api;
Object.assign(api, adhoc_api);
}

View File

@ -1,7 +1,8 @@
import log from '../../log';
import u from '../../utils/form';
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 {

View File

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

View File

@ -1,14 +1,15 @@
import ChatRoomOccupant from './occupant.js';
import u from '../../utils/form';
import { Collection } from '@converse/skeletor/src/collection.js';
import { MUC_ROLE_WEIGHTS } from './constants.js';
import { Model } from '@converse/skeletor/src/model.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 { getAutoFetchedAffiliationLists } from './utils.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.

View File

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

View File

@ -5,13 +5,17 @@
*/
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.
* @private
* @method u#webForm2xForm
* @param { DOMElement } field - the field to convert
*/
u.webForm2xForm = function (field) {
export function webForm2xForm (field) {
const name = field.getAttribute('name');
if (!name) {
return null; // See #1924
@ -26,11 +30,10 @@ u.webForm2xForm = function (field) {
} else {
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;

View File

@ -1,25 +1,24 @@
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 { CustomElement } from 'shared/components/element.js';
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 {
static get properties () {
return {
'alert': { 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 },
'view': { type: String },
}
};
}
constructor () {
@ -31,12 +30,7 @@ export default class AdHocCommands extends CustomElement {
}
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) {
@ -50,7 +44,7 @@ export default class AdHocCommands extends CustomElement {
const jid = form_data.get('jid').trim();
let supported;
try {
supported = await api.disco.supports(Strophe.NS.ADHOC, jid)
supported = await api.disco.supports(Strophe.NS.ADHOC, jid);
} catch (e) {
log.error(e);
} finally {
@ -79,59 +73,117 @@ export default class AdHocCommands extends CustomElement {
ev.preventDefault();
const node = ev.target.getAttribute('data-command-node');
const cmd = this.commands.filter(c => c.node === node)[0];
this.showform !== node && await fetchCommandForm(cmd);
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) {
ev.preventDefault();
this.nonce = u.getUniqueId();
this.showform = ''
}
async runCommand (ev) {
executeAction (ev) {
ev.preventDefault();
const form_data = new FormData(ev.target);
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}`);
}
}
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 node = form_data.get('command_node').trim();
const cmd = this.commands.filter(c => c.node === node)[0];
cmd.alert = null;
this.nonce = u.getUniqueId();
delete cmd.alert;
this.requestUpdate();
const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
const config_array = inputs
const inputs = action === 'prev' ? [] :
sizzle(':input:not([type=button]):not([type=submit])', form)
.filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
.map(u.webForm2xForm)
.map(getNameAndValue)
.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());
const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs);
let result;
try {
result = await api.sendIQ(iq);
} catch (e) {
const { fields, status, note, instructions, actions } = response;
if (status === 'error') {
cmd.alert_type = 'danger';
cmd.alert = __(
'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;
} else {
cmd.alert = 'Done';
}
if (status === 'executing') {
cmd.alert = __('Executing');
cmd.fields = fields;
cmd.instructions = instructions;
cmd.alert_type = 'primary';
this.nonce = u.getUniqueId();
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 {
log.error(`Unexpected status for ad-hoc command: ${status}`);
cmd.alert = __('Error: unexpected result');
cmd.alert_type = 'danger';
}
this.requestUpdate();
}
}

View File

@ -1,25 +1,41 @@
import { __ } from 'i18n';
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`
<span> <!-- Don't remove this <span>,
this is a workaround for a lit bug where a <form> cannot be removed
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>` : '' }
<fieldset class="form-group">
<input type="hidden" name="command_node" value="${command.node}"/>
<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 }
</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>
</form>
</span>

View File

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

View File

@ -5,7 +5,7 @@ import { getAutoCompleteList } from 'plugins/muc-views/utils.js';
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_instructions = __(
'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');
return html`
${ 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}>
<fieldset class="form-group">
<label>
@ -22,6 +24,7 @@ export default (el, o) => {
<p class="form-help">${i18n_choose_service_instructions}</p>
<converse-autocomplete
.getAutoCompleteList="${getAutoCompleteList}"
required
placeholder="${i18n_jid_placeholder}"
name="jid">
</converse-autocomplete>
@ -34,7 +37,7 @@ export default (el, o) => {
<fieldset class="form-group">
<ul class="list-group">
<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>
</fieldset>`
: '' }

View File

@ -16,11 +16,8 @@ describe("Ad-hoc commands", function () {
const adhoc_form = modal.querySelector('converse-adhoc-commands');
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');
@ -31,7 +28,8 @@ describe("Ad-hoc commands", function () {
<iq type="result"
id="${iq.getAttribute("id")}"
to="${_converse.jid}"
from="${entity_jid}">
from="${entity_jid}"
xmlns="jabber:client">
<query xmlns="http://jabber.org/protocol/disco#items"
node="http://jabber.org/protocol/commands">
<item jid="${entity_jid}"
@ -125,12 +123,443 @@ describe("Ad-hoc commands", function () {
expect(inputs[4].getAttribute('name')).toBe('password');
expect(inputs[4].getAttribute('type')).toBe('password');
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('value')).toBe('Hide');
expect(inputs[6].getAttribute('value')).toBe('Cancel');
inputs[6].click();
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>
`));
}));
});

View File

@ -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 = [];
}
}

View File

@ -5,7 +5,7 @@ import tpl_form_url from "templates/form_url.js";
import tpl_form_username from "templates/form_username.js";
import tpl_register_panel from "./templates/register_panel.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 { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core.js";
@ -318,7 +318,7 @@ class RegisterPanel extends ElementView {
getFormFields (stanza) {
if (this.form_type === 'xform') {
return Array.from(stanza.querySelectorAll('field')).map(field =>
utils.xForm2TemplateResult(field, stanza, {'domain': this.domain})
u.xForm2TemplateResult(field, stanza, {'domain': this.domain})
);
} else {
return this.getLegacyFormFields();
@ -420,7 +420,7 @@ class RegisterPanel extends ElementView {
if (this.form_type === 'xform') {
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());
} else {
inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));

View File

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

View File

@ -4,8 +4,21 @@ const converse = window.converse;
converse.load();
const { u, sizzle, Strophe, dayjs, $iq, $msg, $pres } = converse.env;
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) {
if (typeof promise_names === "function") {
func = promise_names;
@ -670,7 +683,7 @@ async function _initConverse (settings) {
name[last] = name[last].charAt(0).toUpperCase()+name[last].slice(1);
fullname = name.join(' ');
}
const vcard = $iq().c('vCard').c('FN').t(fullname).nodeTree;
const vcard = $iq().c('vCard').c('FN').t(fullname).tree();
return {
'stanza': vcard,
'fullname': vcard.querySelector('FN')?.textContent,

View File

@ -1,6 +1,12 @@
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>
`;
};

View File

@ -22,7 +22,7 @@ import { converse } from '@converse/headless/core';
import { getURI, isAudioURL, isImageURL, isVideoURL } from '@converse/headless/utils/url.js';
import { render } from 'lit';
const { sizzle } = converse.env;
const { sizzle, Strophe } = converse.env;
const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
@ -54,6 +54,93 @@ const XFORM_VALIDATE_TYPE_MAP = {
'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) {
const type = XFORM_TYPE_MAP[field.getAttribute('type')]
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;

View File

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