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) ## 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

View File

@ -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 = new Promise((resolve, reject) => connection.sendIQ(stanza, resolve, reject, timeout));
promise.catch(e => { 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);

View File

@ -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; const { Strophe, u, stx, $iq } = converse.env;
let _converse, api; 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);
} }

View File

@ -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 {

View File

@ -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'];

View File

@ -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.

View File

@ -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,

View File

@ -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(` return u.toStanza(tpl_xform_field(
<field var="${name}"> name,
${ value.constructor === Array ? Array.isArray(value) ? value.map(tpl_xform_value) : tpl_xform_value(value),
value.map(v => `<value>${v}</value>`) : ));
`<value>${value}</value>` } }
</field>`);
}; u.webForm2xForm = webForm2xForm;
export default u;

View File

@ -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 { api, converse } from '@converse/headless/core.js';
import { fetchCommandForm } from './utils.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 'commands': { type: Array },
'fetching': { type: Boolean }, // Used to force re-rendering '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, { return tpl_adhoc(this)
'hideCommandForm': ev => this.hideCommandForm(ev),
'runCommand': ev => this.runCommand(ev),
'showform': this.showform,
'toggleCommandForm': ev => this.toggleCommandForm(ev),
});
} }
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); if (this.showform === node) {
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) { clearCommand (cmd) {
ev.preventDefault(); delete cmd.alert;
const form_data = new FormData(ev.target); 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; delete cmd.alert;
this.nonce = u.getUniqueId(); this.requestUpdate();
const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target); const inputs = action === 'prev' ? [] :
const config_array = inputs sizzle(':input:not([type=button]):not([type=submit])', form)
.filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name'))) .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name')))
.map(u.webForm2xForm) .map(getNameAndValue)
.filter(n => n); .filter(n => n);
const iq = $iq({to: jid, type: "set"}) const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs);
.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; const { fields, status, note, instructions, actions } = response;
try {
result = await api.sendIQ(iq); if (status === 'error') {
} catch (e) {
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'); return this.requestUpdate();
log.error(e);
} }
if (result) { if (status === 'executing') {
cmd.alert = result.querySelector('note')?.textContent; 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 { } else {
cmd.alert = 'Done'; log.error(`Unexpected status for ad-hoc command: ${status}`);
cmd.alert = __('Completed');
cmd.alert_type = 'primary';
} }
cmd.alert_type = 'primary'; this.requestUpdate();
this.nonce = u.getUniqueId(); }
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 { __ } from 'i18n';
import { html } from "lit"; import { html } from "lit";
export default (o, command) => {
const i18n_hide = __('Hide'); const action_map = {
const i18n_run = __('Execute'); 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}"> ${ command.actions.map((action) =>
<input type="button" class="btn btn-secondary button-cancel" value="${i18n_hide}" @click=${o.hideCommandForm}> 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>

View File

@ -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>
`; `;

View File

@ -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>`
: '' } : '' }

View File

@ -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"]'); adhoc_form.querySelector('input[name="jid"]').value = entity_jid;
input.value = entity_jid; adhoc_form.querySelector('input[type="submit"]').click();
const submit = adhoc_form.querySelector('input[type="submit"]');
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('type')).toBe('button');
expect(inputs[5].getAttribute('value')).toBe('Execute'); 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>
`));
}));
});

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_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));

View File

@ -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=button],
input[type=submit] { input[type=submit] {
padding: 0.5em; margin-right: 0.25em;
}
input[type=button],
input[type=submit] {
padding-left: 1em;
padding-right: 1em;
border: none; border: none;
} }
input.error { input.error {

View File

@ -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,

View File

@ -1,6 +1,12 @@
import { html } from "lit"; import { html } from "lit";
import u from '@converse/headless/utils/core.js';
export default (o) => html` export default (o) => {
<label class="label-ta">${o.label}</label> const id = u.getUniqueId();
<textarea name="${o.name}">${o.value}</textarea> 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 { 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;

View File

@ -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,