Browse Source

Extract moderator tools functionality and put it in a component

This makes it easier for 3rd parties to embed it in other modals (besides the bootstrap modal).
JC Brand 4 years ago
parent
commit
0242fdb020

+ 27 - 3
src/headless/plugins/muc/affiliations/utils.js

@@ -2,10 +2,11 @@
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
+import { AFFILIATIONS } from '@converse/headless/plugins/muc/index.js';
 import difference from 'lodash-es/difference';
 import indexOf from 'lodash-es/indexOf';
 import log from "@converse/headless/log";
-import { api, converse } from '@converse/headless/core.js';
+import { _converse, api, converse } from '@converse/headless/core.js';
 import { parseMemberListIQ } from '../parsers.js';
 
 const { Strophe, $iq, u } = converse.env;
@@ -20,19 +21,20 @@ const { Strophe, $iq, u } = converse.env;
  * @returns { Promise<MemberListItem[]> }
  */
 export async function getAffiliationList (affiliation, muc_jid) {
+    const { __ } = _converse;
     const iq = $iq({ 'to': muc_jid, 'type': 'get' })
         .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
         .c('item', { 'affiliation': affiliation });
     const result = await api.sendIQ(iq, null, false);
     if (result === null) {
-        const err_msg = `Error: timeout while fetching ${affiliation} list for MUC ${muc_jid}`;
+        const err_msg = __('Error: timeout while fetching %1s list for MUC %2s', affiliation, muc_jid);
         const err = new Error(err_msg);
         log.warn(err_msg);
         log.warn(result);
         return err;
     }
     if (u.isErrorStanza(result)) {
-        const err_msg = `Error: not allowed to fetch ${affiliation} list for MUC ${muc_jid}`;
+        const err_msg = __('Error: not allowed to fetch %1s list for MUC %2s', affiliation, muc_jid);
         const err = new Error(err_msg);
         log.warn(err_msg);
         log.warn(result);
@@ -43,6 +45,28 @@ export async function getAffiliationList (affiliation, muc_jid) {
         .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0));
 }
 
+/**
+ * Given an occupant model, see which affiliations may be assigned to that user.
+ * @param { Model } occupant
+ * @returns { ('owner', 'admin', 'member', 'outcast', 'none')[] } - An array of assignable affiliations
+ */
+export function getAssignableAffiliations (occupant) {
+    let disabled = api.settings.get('modtools_disable_assign');
+    if (!Array.isArray(disabled)) {
+        disabled = disabled ? AFFILIATIONS : [];
+    }
+    if (occupant.get('affiliation') === 'owner') {
+        return AFFILIATIONS.filter(a => !disabled.includes(a));
+    } else if (occupant.get('affiliation') === 'admin') {
+        return AFFILIATIONS.filter(a => !['owner', 'admin', ...disabled].includes(a));
+    } else {
+        return [];
+    }
+}
+
+// Necessary for tests
+_converse.getAssignableAffiliations = getAssignableAffiliations;
+
 /**
  * Send IQ stanzas to the server to modify affiliations for users in this groupchat.
  * See: https://xmpp.org/extensions/xep-0045.html#modifymember

+ 1 - 0
src/headless/plugins/muc/index.js

@@ -236,6 +236,7 @@ converse.plugins.add('converse-muc', {
             'auto_register_muc_nickname': false,
             'hide_muc_participants': false,
             'locked_muc_domain': false,
+            'modtools_disable_assign': false,
             'muc_clear_messages_on_leave': true,
             'muc_domain': undefined,
             'muc_fetch_members': true,

+ 21 - 0
src/headless/plugins/muc/utils.js

@@ -0,0 +1,21 @@
+import { ROLES } from '@converse/headless/plugins/muc/index.js';
+import { _converse, api } from '@converse/headless/core.js';
+
+/**
+ * Given an occupant model, see which roles may be assigned to that user.
+ * @param { Model } occupant
+ * @returns { ('moderator', 'participant', 'visitor')[] } - An array of assignable roles
+ */
+export function getAssignableRoles (occupant) {
+    let disabled = api.settings.get('modtools_disable_assign');
+    if (!Array.isArray(disabled)) {
+        disabled = disabled ? ROLES : [];
+    }
+    if (occupant.get('role') === 'moderator') {
+        return ROLES.filter(r => !disabled.includes(r));
+    } else {
+        return [];
+    }
+}
+
+Object.assign(_converse, { getAssignableRoles });

+ 1 - 1
src/plugins/chatview/heading.js

@@ -4,7 +4,7 @@ import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless/core";
 
-import './styles//chat-head.scss';
+import './styles/chat-head.scss';
 
 
 export default class ChatHeading extends CustomElement {

+ 0 - 1
src/plugins/muc-views/index.js

@@ -44,7 +44,6 @@ converse.plugins.add('converse-muc-views', {
             'cache_muc_messages': true,
             'locked_muc_nickname': false,
             'modtools_disable_query': [],
-            'modtools_disable_assign': false,
             'muc_disable_slash_commands': false,
             'muc_mention_autocomplete_filter': 'contains',
             'muc_mention_autocomplete_min_chars': 0,

+ 3 - 191
src/plugins/muc-views/modals/moderator-tools.js

@@ -1,14 +1,6 @@
+import '../modtools.js';
 import BootstrapModal from "modals/base.js";
-import log from "@converse/headless/log";
-import tpl_moderator_tools_modal from "../templates/moderator-tools.js";
-import { AFFILIATIONS, ROLES } from "@converse/headless/plugins/muc/index.js";
-import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
-import { getAffiliationList, setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js'
-
-const { Strophe, sizzle } = converse.env;
-const u = converse.env.utils;
-
+import tpl_moderator_tools from './templates/moderator-tools.js';
 
 const ModeratorToolsModal = BootstrapModal.extend({
     id: "converse-modtools-modal",
@@ -17,190 +9,10 @@ const ModeratorToolsModal = BootstrapModal.extend({
     initialize (attrs) {
         this.muc = attrs.muc;
         BootstrapModal.prototype.initialize.apply(this, arguments);
-
-        this.affiliations_filter = '';
-        this.roles_filter = '';
-
-        this.listenTo(this.model, 'change:role', () => {
-            this.users_with_role = this.muc.getOccupantsWithRole(this.model.get('role'));
-            this.render();
-        });
-        this.listenTo(this.model, 'change:affiliation', async () => {
-            this.loading_users_with_affiliation = true;
-            this.users_with_affiliation = null;
-            this.render();
-            const affiliation = this.model.get('affiliation');
-            if (this.shouldFetchAffiliationsList()) {
-                const muc_jid = this.muc.get('jid');
-                this.users_with_affiliation = await getAffiliationList(affiliation, muc_jid);
-            } else {
-                this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(affiliation);
-            }
-            this.loading_users_with_affiliation = false;
-            this.render();
-        });
     },
 
     toHTML () {
-        const occupant = this.muc.occupants.findWhere({'jid': _converse.bare_jid});
-        return tpl_moderator_tools_modal(Object.assign(this.model.toJSON(), {
-            'affiliations_filter': this.affiliations_filter,
-            'assignAffiliation': ev => this.assignAffiliation(ev),
-            'assignRole': ev => this.assignRole(ev),
-            'assignable_affiliations': this.getAssignableAffiliations(occupant),
-            'assignable_roles': this.getAssignableRoles(occupant),
-            'filterAffiliationResults': ev => this.filterAffiliationResults(ev),
-            'filterRoleResults': ev => this.filterRoleResults(ev),
-            'loading_users_with_affiliation': this.loading_users_with_affiliation,
-            'queryAffiliation': ev => this.queryAffiliation(ev),
-            'queryRole': ev => this.queryRole(ev),
-            'queryable_affiliations': AFFILIATIONS.filter(a => !_converse.modtools_disable_query.includes(a)),
-            'queryable_roles': ROLES.filter(a => !_converse.modtools_disable_query.includes(a)),
-            'roles_filter': this.roles_filter,
-            'switchTab': ev => this.switchTab(ev),
-            'toggleForm': ev => this.toggleForm(ev),
-            'users_with_affiliation': this.users_with_affiliation,
-            'users_with_role': this.users_with_role
-        }));
-    },
-
-    getAssignableAffiliations (occupant) {
-        let disabled = api.settings.get('modtools_disable_assign');
-        if (!Array.isArray(disabled)) {
-            disabled = disabled ? AFFILIATIONS : [];
-        }
-
-        if (occupant.get('affiliation') === 'owner') {
-            return AFFILIATIONS.filter(a => !disabled.includes(a));
-        } else if (occupant.get('affiliation') === 'admin') {
-            return AFFILIATIONS.filter(a => !['owner', 'admin', ...disabled].includes(a));
-        } else {
-            return [];
-        }
-    },
-
-    getAssignableRoles (occupant) {
-        let disabled = api.settings.get('modtools_disable_assign');
-        if (!Array.isArray(disabled)) {
-            disabled = disabled ? ROLES : [];
-        }
-
-        if (occupant.get('role') === 'moderator') {
-            return ROLES.filter(r => !disabled.includes(r));
-        } else {
-            return [];
-        }
-    },
-
-    shouldFetchAffiliationsList () {
-        const affiliation = this.model.get('affiliation');
-        if (affiliation === 'none') {
-            return false;
-        }
-        const chatroom = this.muc;
-        const auto_fetched_affs = chatroom.occupants.getAutoFetchedAffiliationLists();
-        if (auto_fetched_affs.includes(affiliation)) {
-            return false;
-        } else {
-            return true;
-        }
-    },
-
-    toggleForm (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        const form_class = ev.target.getAttribute('data-form');
-        const form = u.ancestor(ev.target, '.list-group-item').querySelector(`.${form_class}`);
-        if (u.hasClass('hidden', form)) {
-            u.removeClass('hidden', form);
-        } else {
-            u.addClass('hidden', form);
-        }
-    },
-
-    filterRoleResults (ev) {
-        this.roles_filter = ev.target.value;
-        this.render();
-    },
-
-    filterAffiliationResults (ev) {
-        this.affiliations_filter = ev.target.value;
-        this.render();
-    },
-
-    queryRole (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        const data = new FormData(ev.target);
-        const role = data.get('role');
-        this.model.set({'role': null}, {'silent': true});
-        this.model.set({'role': role});
-    },
-
-    queryAffiliation (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        const data = new FormData(ev.target);
-        const affiliation = data.get('affiliation');
-        this.model.set({'affiliation': null}, {'silent': true});
-        this.model.set({'affiliation': affiliation});
-    },
-
-    async assignAffiliation (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        const data = new FormData(ev.target);
-        const affiliation = data.get('affiliation');
-        const attrs = {
-            'jid': data.get('jid'),
-            'reason': data.get('reason')
-        }
-        const current_affiliation = this.model.get('affiliation');
-        const muc_jid = this.muc.get('jid');
-        try {
-            await setAffiliation(affiliation, muc_jid, [attrs]);
-        } catch (e) {
-            if (e === null) {
-                this.alert(__('Timeout error while trying to set the affiliation'), 'danger');
-            } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                this.alert(__('Sorry, you\'re not allowed to make that change'), 'danger');
-            } else {
-                this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger');
-            }
-            log.error(e);
-            return;
-        }
-        this.alert(__('Affiliation changed'), 'primary');
-        await this.muc.occupants.fetchMembers()
-        this.model.set({'affiliation': null}, {'silent': true});
-        this.model.set({'affiliation': current_affiliation});
-    },
-
-    assignRole (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        const data = new FormData(ev.target);
-        const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick'));
-        const role = data.get('role');
-        const reason = data.get('reason');
-        const current_role = this.model.get('role');
-        this.muc.setRole(occupant, role, reason,
-            () => {
-                this.alert(__('Role changed'), 'primary');
-                this.model.set({'role': null}, {'silent': true});
-                this.model.set({'role': current_role});
-            },
-            (e) => {
-                if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                    this.alert(__('You\'re not allowed to make that change'), 'danger');
-                } else {
-                    this.alert(__('Sorry, something went wrong while trying to set the role'), 'danger');
-                    if (u.isErrorObject(e)) {
-                        log.error(e);
-                    }
-                }
-            }
-        );
+        return tpl_moderator_tools(this);
     }
 });
 

+ 19 - 0
src/plugins/muc-views/modals/templates/moderator-tools.js

@@ -0,0 +1,19 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { modal_header_close_button } from "modals/templates/buttons.js"
+
+export default (o) => {
+    const i18n_moderator_tools = __('Moderator Tools');
+    return html`
+        <div class="modal-dialog" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_moderator_tools}</h5>
+                    ${modal_header_close_button}
+                </div>
+                <div class="modal-body d-flex flex-column">
+                    <converse-modtools .muc=${o.muc} .model=${o.model}></converse-modtools>
+                </div>
+            </div>
+        </div>`;
+}

+ 225 - 0
src/plugins/muc-views/modtools.js

@@ -0,0 +1,225 @@
+import log from '@converse/headless/log';
+import tpl_moderator_tools from './templates/moderator-tools.js';
+import { AFFILIATIONS, ROLES } from '@converse/headless/plugins/muc/index.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+import { getAssignableRoles } from '@converse/headless/plugins/muc/utils.js';
+import {
+    getAffiliationList,
+    getAssignableAffiliations,
+    setAffiliation,
+} from '@converse/headless/plugins/muc/affiliations/utils.js';
+
+const { Strophe, sizzle, u } = converse.env;
+
+export default class ModeratorTools extends CustomElement {
+    static get properties () {
+        return {
+            affiliations_filter: { type: String, attribute: false },
+            alert_message: { type: String, attribute: false },
+            alert_type: { type: String, attribute: false },
+            model: { type: Object },
+            muc: { type: Object },
+            roles_filter: { type: String, attribute: false },
+            users_with_affiliation: { type: Array, attribute: false },
+            users_with_role: { type: Array, attribute: false },
+        };
+    }
+
+    constructor () {
+        super();
+        this.affiliations_filter = '';
+        this.roles_filter = '';
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.initialize();
+    }
+
+    initialize () {
+        this.listenTo(this.model, 'change:role', this.onSearchRoleChange, this);
+        this.listenTo(this.model, 'change:affiliation', this.onSearchAffiliationChange, this);
+    }
+
+    render () {
+        const occupant = this.muc.occupants.findWhere({ 'jid': _converse.bare_jid });
+        return tpl_moderator_tools(
+            Object.assign(this.model.toJSON(), {
+                'affiliations_filter': this.affiliations_filter,
+                'alert_message': this.alert_message,
+                'alert_type': this.alert_type,
+                'assignAffiliation': ev => this.assignAffiliation(ev),
+                'assignRole': ev => this.assignRole(ev),
+                'assignable_affiliations': getAssignableAffiliations(occupant),
+                'assignable_roles': getAssignableRoles(occupant),
+                'filterAffiliationResults': ev => this.filterAffiliationResults(ev),
+                'filterRoleResults': ev => this.filterRoleResults(ev),
+                'loading_users_with_affiliation': this.loading_users_with_affiliation,
+                'queryAffiliation': ev => this.queryAffiliation(ev),
+                'queryRole': ev => this.queryRole(ev),
+                'queryable_affiliations': AFFILIATIONS.filter(a => !api.settings.get('modtools_disable_query').includes(a)),
+                'queryable_roles': ROLES.filter(a => !api.settings.get('modtools_disable_query').includes(a)),
+                'roles_filter': this.roles_filter,
+                'switchTab': ev => this.switchTab(ev),
+                'toggleForm': ev => this.toggleForm(ev),
+                'users_with_affiliation': this.users_with_affiliation,
+                'users_with_role': this.users_with_role,
+            })
+        );
+    }
+
+    async onSearchAffiliationChange () {
+        this.clearAlert();
+        this.loading_users_with_affiliation = true;
+        this.users_with_affiliation = null;
+
+        const affiliation = this.model.get('affiliation');
+        if (this.shouldFetchAffiliationsList()) {
+            const muc_jid = this.muc.get('jid');
+            const result = await getAffiliationList(affiliation, muc_jid);
+            if (result instanceof Error) {
+                this.alert(result.message, 'danger');
+                this.users_with_affiliation = [];
+            } else {
+                this.users_with_affiliation = result;
+            }
+        } else {
+            this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(affiliation);
+        }
+        this.loading_users_with_affiliation = false;
+    }
+
+    onSearchRoleChange () {
+        this.clearAlert();
+        this.users_with_role = this.muc.getOccupantsWithRole(this.model.get('role'));
+    }
+
+    shouldFetchAffiliationsList () {
+        const affiliation = this.model.get('affiliation');
+        if (affiliation === 'none') {
+            return false;
+        }
+        const chatroom = this.muc;
+        const auto_fetched_affs = chatroom.occupants.getAutoFetchedAffiliationLists();
+        if (auto_fetched_affs.includes(affiliation)) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+
+    toggleForm (ev) { // eslint-disable-line class-methods-use-this
+        ev.stopPropagation();
+        ev.preventDefault();
+        const form_class = ev.target.getAttribute('data-form');
+        const form = u.ancestor(ev.target, '.list-group-item').querySelector(`.${form_class}`);
+        if (u.hasClass('hidden', form)) {
+            u.removeClass('hidden', form);
+        } else {
+            u.addClass('hidden', form);
+        }
+    }
+
+    filterRoleResults (ev) {
+        this.roles_filter = ev.target.value;
+        this.render();
+    }
+
+    filterAffiliationResults (ev) {
+        this.affiliations_filter = ev.target.value;
+    }
+
+    queryRole (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        const data = new FormData(ev.target);
+        const role = data.get('role');
+        this.model.set({ 'role': null }, { 'silent': true });
+        this.model.set({ 'role': role });
+    }
+
+    queryAffiliation (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        const data = new FormData(ev.target);
+        const affiliation = data.get('affiliation');
+        this.model.set({ 'affiliation': null }, { 'silent': true });
+        this.model.set({ 'affiliation': affiliation });
+    }
+
+    alert (message, type) {
+        this.alert_message = message;
+        this.alert_type = type;
+    }
+
+    clearAlert () {
+        this.alert_message = undefined;
+        this.alert_type = undefined;
+    }
+
+    async assignAffiliation (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        this.clearAlert();
+        const data = new FormData(ev.target);
+        const affiliation = data.get('affiliation');
+        const attrs = {
+            'jid': data.get('jid'),
+            'reason': data.get('reason'),
+        };
+        const current_affiliation = this.model.get('affiliation');
+        const muc_jid = this.muc.get('jid');
+        try {
+            await setAffiliation(affiliation, muc_jid, [attrs]);
+        } catch (e) {
+            if (e === null) {
+                this.alert(__('Timeout error while trying to set the affiliation'), 'danger');
+            } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                this.alert(__("Sorry, you're not allowed to make that change"), 'danger');
+            } else {
+                this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger');
+            }
+            log.error(e);
+            return;
+        }
+        await this.muc.occupants.fetchMembers();
+        this.model.set({ 'affiliation': null }, { 'silent': true });
+        this.model.set({ 'affiliation': current_affiliation });
+        this.alert(__('Affiliation changed'), 'primary');
+    }
+
+    assignRole (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        this.clearAlert();
+        const data = new FormData(ev.target);
+        const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick'));
+        const role = data.get('role');
+        const reason = data.get('reason');
+        const current_role = this.model.get('role');
+        this.muc.setRole(
+            occupant,
+            role,
+            reason,
+            () => {
+                this.alert(__('Role changed'), 'primary');
+                this.model.set({ 'role': null }, { 'silent': true });
+                this.model.set({ 'role': current_role });
+            },
+            e => {
+                if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                    this.alert(__("You're not allowed to make that change"), 'danger');
+                } else {
+                    this.alert(__('Sorry, something went wrong while trying to set the role'), 'danger');
+                    if (u.isErrorObject(e)) {
+                        log.error(e);
+                    }
+                }
+            }
+        );
+    }
+}
+
+api.elements.define('converse-modtools', ModeratorTools);

+ 73 - 86
src/plugins/muc-views/templates/moderator-tools.js

@@ -1,7 +1,6 @@
 import spinner from "templates/spinner.js";
 import { __ } from 'i18n';
 import { html } from "lit";
-import { modal_header_close_button } from "modals/templates/buttons.js"
 
 
 function getRoleHelpText (role) {
@@ -135,13 +134,13 @@ const affiliation_list_item = (o) => html`
 `;
 
 
-const tpl_navigation = (o) => html`
+const tpl_navigation = () => html`
     <ul class="nav nav-pills justify-content-center">
         <li role="presentation" class="nav-item">
-            <a class="nav-link active" id="affiliations-tab" href="#affiliations-tabpanel" aria-controls="affiliations-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>Affiliations</a>
+            <a class="nav-link active" id="affiliations-tab" href="#affiliations-tabpanel" aria-controls="affiliations-tabpanel" role="tab" data-toggle="tab">Affiliations</a>
         </li>
         <li role="presentation" class="nav-item">
-            <a class="nav-link" id="roles-tab" href="#roles-tabpanel" aria-controls="roles-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>Roles</a>
+            <a class="nav-link" id="roles-tab" href="#roles-tabpanel" aria-controls="roles-tabpanel" role="tab" data-toggle="tab">Roles</a>
         </li>
     </ul>
 `;
@@ -149,7 +148,6 @@ const tpl_navigation = (o) => html`
 
 export default (o) => {
     const i18n_affiliation = __('Affiliation');
-    const i18n_moderator_tools = __('Moderator Tools');
     const i18n_no_users_with_aff = __('No users with that affiliation found.')
     const i18n_no_users_with_role = __('No users with that role found.');
     const i18n_filter = __('Type here to filter the search results');
@@ -167,94 +165,83 @@ export default (o) => {
     );
     const show_both_tabs = o.queryable_roles.length && o.queryable_affiliations.length;
     return html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_moderator_tools}</h5>
-                ${modal_header_close_button}
-            </div>
-            <div class="modal-body d-flex flex-column">
-                <span class="modal-alert"></span>
-
-                ${ show_both_tabs ? tpl_navigation(o) : '' }
-
-                <div class="tab-content">
-                    <div class="tab-pane tab-pane--columns ${ o.queryable_affiliations.length ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
-                        <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}>
-                            <p class="helptext pb-3">${i18n_helptext_affiliation}</p>
-                            <div class="form-group">
-                                <label for="affiliation">
-                                    <strong>${i18n_affiliation}:</strong>
-                                </label>
-                                <div class="row">
-                                    <div class="col">
-                                        <select class="custom-select select-affiliation" name="affiliation">
-                                            ${o.queryable_affiliations.map(item => affiliation_option(Object.assign({item}, o)))}
-                                        </select>
-                                    </div>
-                                    <div class="col">
-                                        <input type="submit" class="btn btn-primary" name="users_with_affiliation" value="${i18n_show_users}"/>
-                                    </div>
-                                </div>
-                                <div class="row">
-                                    <div class="col mt-3">
-                                        ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length > 5) ?
-                                            html`<input class="form-control" .value="${o.affiliations_filter}" @keyup=${o.filterAffiliationResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
-                                    </div>
-                                </div>
-
-                                ${ getAffiliationHelpText(o.affiliation) ?
-                                    html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getAffiliationHelpText(o.affiliation)}</p></div></div>` : '' }
+        ${o.alert_message ? html`<div class="alert alert-${o.alert_type}" role="alert">${o.alert_message}</div>` : '' }
+        ${ show_both_tabs ? tpl_navigation() : '' }
+
+        <div class="tab-content">
+            <div class="tab-pane tab-pane--columns ${ o.queryable_affiliations.length ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
+                <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}>
+                    <p class="helptext pb-3">${i18n_helptext_affiliation}</p>
+                    <div class="form-group">
+                        <label for="affiliation">
+                            <strong>${i18n_affiliation}:</strong>
+                        </label>
+                        <div class="row">
+                            <div class="col">
+                                <select class="custom-select select-affiliation" name="affiliation">
+                                    ${o.queryable_affiliations.map(item => affiliation_option(Object.assign({item}, o)))}
+                                </select>
+                            </div>
+                            <div class="col">
+                                <input type="submit" class="btn btn-primary" name="users_with_affiliation" value="${i18n_show_users}"/>
+                            </div>
+                        </div>
+                        <div class="row">
+                            <div class="col mt-3">
+                                ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length > 5) ?
+                                    html`<input class="form-control" .value="${o.affiliations_filter}" @keyup=${o.filterAffiliationResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
                             </div>
-                        </form>
-                        <div class="scrollable-container">
-                            <ul class="list-group list-group--users">
-                                ${ (o.loading_users_with_affiliation) ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
-                                ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length === 0) ?
-                                        html`<li class="list-group-item">${i18n_no_users_with_aff}</li>` : '' }
-
-                                ${ (o.users_with_affiliation instanceof Error) ?
-                                        html`<li class="list-group-item">${o.users_with_affiliation.message}</li>` :
-                                        (o.users_with_affiliation || []).map(item => ((item.nick || item.jid).match(new RegExp(o.affiliations_filter, 'i')) ? affiliation_list_item(Object.assign({item}, o)) : '')) }
-                            </ul>
                         </div>
-                    </div>
 
-                    <div class="tab-pane tab-pane--columns ${ !show_both_tabs && o.queryable_roles.length ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
-                        <form class="converse-form query-role" @submit=${o.queryRole}>
-                            <p class="helptext pb-3">${i18n_helptext_role}</p>
-                            <div class="form-group">
-                                <label for="role"><strong>${i18n_role}:</strong></label>
-                                <div class="row">
-                                    <div class="col">
-                                        <select class="custom-select select-role" name="role">
-                                            ${o.queryable_roles.map(item => role_option(Object.assign({item}, o)))}
-                                        </select>
-                                    </div>
-                                    <div class="col">
-                                        <input type="submit" class="btn btn-primary" name="users_with_role" value="${i18n_show_users}"/>
-                                    </div>
-                                </div>
-                                <div class="row">
-                                    <div class="col mt-3">
-                                        ${ (Array.isArray(o.users_with_role) && o.users_with_role.length > 5) ?
-                                            html`<input class="form-control" .value="${o.roles_filter}" @keyup=${o.filterRoleResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
-                                    </div>
-                                </div>
+                        ${ getAffiliationHelpText(o.affiliation) ?
+                            html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getAffiliationHelpText(o.affiliation)}</p></div></div>` : '' }
+                    </div>
+                </form>
+                <div class="scrollable-container">
+                    <ul class="list-group list-group--users">
+                        ${ (o.loading_users_with_affiliation) ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
+                        ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length === 0) ?
+                                html`<li class="list-group-item">${i18n_no_users_with_aff}</li>` : '' }
+
+                        ${ (o.users_with_affiliation instanceof Error) ?
+                                html`<li class="list-group-item">${o.users_with_affiliation.message}</li>` :
+                                (o.users_with_affiliation || []).map(item => ((item.nick || item.jid).match(new RegExp(o.affiliations_filter, 'i')) ? affiliation_list_item(Object.assign({item}, o)) : '')) }
+                    </ul>
+                </div>
+            </div>
 
-                                ${ getRoleHelpText(o.role) ? html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getRoleHelpText(o.role)}</p></div></div>` :  ''}
+            <div class="tab-pane tab-pane--columns ${ !show_both_tabs && o.queryable_roles.length ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
+                <form class="converse-form query-role" @submit=${o.queryRole}>
+                    <p class="helptext pb-3">${i18n_helptext_role}</p>
+                    <div class="form-group">
+                        <label for="role"><strong>${i18n_role}:</strong></label>
+                        <div class="row">
+                            <div class="col">
+                                <select class="custom-select select-role" name="role">
+                                    ${o.queryable_roles.map(item => role_option(Object.assign({item}, o)))}
+                                </select>
+                            </div>
+                            <div class="col">
+                                <input type="submit" class="btn btn-primary" name="users_with_role" value="${i18n_show_users}"/>
+                            </div>
+                        </div>
+                        <div class="row">
+                            <div class="col mt-3">
+                                ${ (Array.isArray(o.users_with_role) && o.users_with_role.length > 5) ?
+                                    html`<input class="form-control" .value="${o.roles_filter}" @keyup=${o.filterRoleResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' }
                             </div>
-                        </form>
-                        <div class="scrollable-container">
-                            <ul class="list-group list-group--users">
-                                ${ o.loading_users_with_role ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
-                                ${ (o.users_with_role && o.users_with_role.length === 0) ? html`<li class="list-group-item">${i18n_no_users_with_role}</li>` : '' }
-                                ${ (o.users_with_role || []).map(item => (item.nick.match(o.roles_filter) ? role_list_item(Object.assign({item}, o)) : '')) }
-                            </ul>
                         </div>
+
+                        ${ getRoleHelpText(o.role) ? html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getRoleHelpText(o.role)}</p></div></div>` :  ''}
                     </div>
+                </form>
+                <div class="scrollable-container">
+                    <ul class="list-group list-group--users">
+                        ${ o.loading_users_with_role ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
+                        ${ (o.users_with_role && o.users_with_role.length === 0) ? html`<li class="list-group-item">${i18n_no_users_with_role}</li>` : '' }
+                        ${ (o.users_with_role || []).map(item => (item.nick.match(o.roles_filter) ? role_list_item(Object.assign({item}, o)) : '')) }
+                    </ul>
                 </div>
             </div>
-        </div>
-    </div>`;
+        </div>`;
 }

+ 11 - 8
src/plugins/muc-views/tests/modtools.js

@@ -338,9 +338,12 @@ describe("The groupchat moderator tool", function () {
         _converse.connection._dataRecv(mock.createRequest(error));
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
 
+        const alert = await u.waitUntil(() => modal.el.querySelector('.alert'));
+        expect(alert.textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
+
         const user_els = modal.el.querySelectorAll('.list-group--users > li');
         expect(user_els.length).toBe(1);
-        expect(user_els[0].textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
+        expect(user_els[0].textContent.trim()).toBe('No users with that affiliation found.');
     }));
 
     it("shows an error message if a particular affiliation may not be set",
@@ -452,23 +455,23 @@ describe("The groupchat moderator tool", function () {
         const message_form = view.querySelector('converse-muc-message-form');
         message_form.onKeyDown(enter);
 
-        const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
+        await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
 
-        expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);
+        expect(_converse.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);
 
         _converse.api.settings.set('modtools_disable_assign', ['owner']);
-        expect(modal.getAssignableAffiliations(occupant)).toEqual(['admin', 'member', 'outcast', 'none']);
+        expect(_converse.getAssignableAffiliations(occupant)).toEqual(['admin', 'member', 'outcast', 'none']);
 
         _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin']);
-        expect(modal.getAssignableAffiliations(occupant)).toEqual(['member', 'outcast', 'none']);
+        expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'outcast', 'none']);
 
         _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin', 'outcast']);
-        expect(modal.getAssignableAffiliations(occupant)).toEqual(['member', 'none']);
+        expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'none']);
 
-        expect(modal.getAssignableRoles(occupant)).toEqual(['moderator', 'participant', 'visitor']);
+        expect(_converse.getAssignableRoles(occupant)).toEqual(['moderator', 'participant', 'visitor']);
 
         _converse.api.settings.set('modtools_disable_assign', ['admin', 'moderator']);
-        expect(modal.getAssignableRoles(occupant)).toEqual(['participant', 'visitor']);
+        expect(_converse.getAssignableRoles(occupant)).toEqual(['participant', 'visitor']);
     }));
 });

+ 2 - 2
webpack.html

@@ -27,8 +27,8 @@
             message_limit: 300,
             auto_register_muc_nickname: true,
             loglevel: 'debug',
-            modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
-            modtools_disable_query: ['moderator', 'participant', 'visitor'],
+            // modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
+            // modtools_disable_query: ['moderator', 'participant', 'visitor'],
             enable_smacks: true,
             // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
             // persistent_store: 'IndexedDB',