Jump to content

MediaWiki:Form-assistant.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/*  [[Mediawiki:Form-assistant.js]]
    @author: L235 ([[User:L235]])

    This script is a form assistant that allows users to submit forms on Wikipedia pages, 
    the answers to which are then posted to a target page.

    Data is stored in a JSON file found at [[Mediawiki:Form-assistant.js/config.json]].

    The form assistant is only available on the [[Wikipedia:Form assistant/Run]] page.

    **JSON schema (examples)**

    Object‑key style:
    {
      "Wikipedia:Form assistant/Run#demo": {
        "title": "Demo survey",
        "instructions": "Please fill the survey.",
        "targetPage": "Wikipedia:Sandbox",
        "prepend": false,
        "onComplete": "Wikipedia:Thank‑you"       – simple string -> redirect
          // or: { "redirectPage": "Wikipedia:Foo" }
          // or: { "text": "''Thanks!''" }   – show wikitext message
          // or: { "html": "<b>Thanks!</b>" } – show raw HTML (use with care)
        "preview": "button",
        "template": { "name": "Template:Example", "subst": true },
        "questions": [
          { "label": "Question A", "type": "text", "templateParam": "1", "default": "foo", "preview": "live" },
          { "label": "Question B", "type": "textarea", "required": true, "templateParam": "2" },
          { "type": "heading", "text": "Choices" },
          { "label": "Question C", "type": "dropdown", "options": ["apples", "bananas"], "templateParam": "3", "default": "bananas" },
          { "label": "Question D", "type": "checkbox", "options": ["cats", "dogs"], "templateParam": "4", "default": ["cats"] },
          { "label": "Question E", "type": "radio", "options": ["noun", "verb"], "required": true, "templateParam": "5", "default": "verb" },
          { "label": "Section title", "type": "text", "required": true, "templateParam": "6" },
          { "type": "static": "''Thanks for participating!''" }
        ]
      }
    }

    **Target page variables:**
    - {{USERNAME}} - Current user's username
    - {{FIELD:templateParam}} - Value from form field (use templateParam as identifier)
    
    **Form options:**
    - "prepend": true/false - Whether to prepend (true) or append (false, default) to target page
    - "preview": Toggle full‑form preview area at bottom of form
      Values (form‑wide or per‑question):
        • "none"   – (default) no preview
        • "live"   – live preview that updates as you type
        • "button" – adds a preview button (form bottom or just after the question)
      (individual questions may set "preview": "live"/"button" for previewing an answer)
    
    Examples:
    - "targetPage": "User talk:{{USERNAME}}" - Posts to current user's talk page
    - "targetPage": "User:{{FIELD:1}}/requests" - Uses value from templateParam "1"
    - "prepend": true - Adds content to top of page instead of bottom
*/
/* global mw, $ */
(function () {
    var CONFIG_PAGE = 'Mediawiki:Form-assistant.js/config.json';
    var ALLOWED_BASE_PAGE = 'Wikipedia:Form assistant/Run';

    mw.loader.using(['mediawiki.api', 'oojs-ui', 'mediawiki.ui.button']).then(function () {
        // Abort early if not on the permitted base page
        var fullPageTitle = mw.config.get('wgPageName').replace(/_/g, ' '); // keeps spaces
        var basePageTitle = fullPageTitle.split('#')[0]; // drop fragment if any
        if (basePageTitle !== ALLOWED_BASE_PAGE) {
            console.log('[form-assistant.js] Not on the permitted base page');
            return; // Silently exit – nothing to do here
        }

        var api = new mw.Api();

        /* ---------- internal‑field counter ------------------------ */
        var mfCounter = 0;

        /* ---------- helper: preview‑mode coercion ------------------ */
        function normalizePreviewMode(v) {
            if (v === 'live' || v === 'button' || v === 'none') return v;
            return 'none';
        }

        /* ---------- helper: debounce ------------------------------ */
        // Returns a function that delays invoking the provided function until after
        // 'wait' milliseconds have elapsed since the last time it was invoked.
        // This is particularly useful for rate-limiting events that occur in quick succession,
        // such as input events during typing.
        function debounce(fn, wait) {
            var t;
            return function () {
                var ctx = this, args = arguments;
                clearTimeout(t);
                t = setTimeout(function () { fn.apply(ctx, args); }, wait);
            };
        }

        /* ---------- helper: build wikitext from answers -------------- */
        function buildWikitext($form, cfg) {
            var params = (cfg.questions || []).filter(function (q) { return q.templateParam; })
                .map(function (q) { return '|' + q.templateParam + '=' + encodeParam(valueOf($form, q)); }).join('');
            var tpl = cfg.template.name || cfg.template;
            if (cfg.template && cfg.template.subst) tpl = 'safesubst:' + tpl;
            return '\n{{' + tpl + params + '}}\n';
        }

        /* ---------- helper: parse wikitext -> safe HTML -------------- */
        function parseWikitext(wt, title) {
            var params = {
                action: 'parse',
                text: wt || '',
                pst: true,                // expand templates
                contentmodel: 'wikitext',
                wrapoutputclass: '',
                disableeditsection: true      // suppress [edit] links inside parsed headings
            };
            if (title) params.title = title;  // give correct namespace context

            return api.post(params).then(function (d) {
                return d.parse.text['*'];
            }).catch(function () {
                // Never inject raw fallback – escape instead
                return $('<div>').text(wt || '').prop('outerHTML');
            });
        }

        /* ---------- helper: escape template parameters --------------- */
        function encodeParam(val) {
            // Ensure string, escape HTML special chars, preserve newlines
            return mw.html.escape(String(val || '')).replace(/\n/g, '&#10;');
        }

        /* ---------- 1. Load JSON config ------------------------------ */
        api.get({
            action: 'query', prop: 'revisions', titles: CONFIG_PAGE,
            rvprop: 'content', formatversion: 2
        }).then(function (data) {
            var page = data.query.pages[0];
            if (!page.revisions) {
                console.error('[form-assistant.js] Config page missing or empty');
                return;
            }
            var raw = page.revisions[0].content;
            var cfg;
            try { cfg = JSON.parse(raw); }
            catch (e) { console.error('[form-assistant.js] JSON parse error:', e); return; }

            // Derive current page key, supporting #section fragments
            var pageTitle = mw.config.get('wgPageName').replace(/_/g, ' ');
            var fragment = (window.location.hash || '').slice(1); // keep underscores
            var currentFull = fragment ? pageTitle + '#' + fragment : pageTitle;

            // Attempt exact match with fragment first, then without
            var formCfg = matchForm(cfg, currentFull) || matchForm(cfg, pageTitle);
            if (formCfg) renderForm(formCfg);
        }).fail(function (err) { console.error('[form-assistant.js] API error:', err); });

        /* ---------- helper: find config for this page ---------------- */
        function matchForm(cfg, page) {
            if (Array.isArray(cfg)) return cfg.find(function (f) { return f.formPage === page; });
            if (cfg[page]) return cfg[page];
            return Object.values(cfg).find(function (f) { return f.formPage === page; });
        }

        /* ---------- 2. Render form ----------------------------------- */
        function renderForm(cfg) {
            /* ---------- 0. Inject author‑supplied CSS --------------- */
            if (cfg.customCSS) {
                // cfg.customCSS is a raw CSS string – load it once per form
                mw.util.addCSS(cfg.customCSS);
            }

            $('#firstHeading').empty();
            var $content = $('#mw-content-text').empty();
            if (cfg.title) $content.append($('<h2>').text(cfg.title));

            /* ---------- generate safe field names ----------------- */
            (cfg.questions || []).forEach(function (q) {
                q._fieldName = 'mf_' + (mfCounter++);
            });

            var promises = [];
            if (cfg.instructions) {
                promises.push(parseWikitext(cfg.instructions, cfg.formPage).then(function (html) { $content.append($(html)); }));
            }

            Promise.all(promises).then(function () {
                /* ---------- 1. Wrapper for whole form -------------- */
                var $formWrapper = $('<div>')
                    .addClass('fa-form-wrapper')
                    .appendTo($content);

                var $form = $('<form>')
                    .addClass('fa-form')
                    .appendTo($formWrapper);

                (cfg.questions || []).forEach(function (q) { insertItem($form, q); });

                /* ---------- 2. Pretty blue submit button ----------- */
                var $submit = $('<button>')
                    .addClass('mw-ui-button mw-ui-progressive fa-submit')
                    .attr('type', 'submit')
                    .text('Submit');

                $form.append($submit);

                /* ---------- 3. Optional full‑form preview ---------- */
                var formPreviewMode = normalizePreviewMode(cfg.preview);
                var $previewBtn, $previewArea;

                if (formPreviewMode !== 'none') {
                    $previewBtn = $('<button>')
                        .addClass('mw-ui-button fa-preview-btn')
                        .attr('type', 'button')
                        .css({ marginLeft: '8px' })
                        .text('Preview');

                    $previewArea = $('<div>')
                        .addClass('fa-form-preview')
                        .css({ border: '1px solid #a2a9b1', padding: '8px', marginTop: '8px' });

                    // Insert elements
                    if (formPreviewMode === 'button') {
                        $form.append($previewBtn, $previewArea);
                        $previewBtn.on('click', function () {
                            var formData   = collectFormData($form, cfg);
                            var wikitext   = buildWikitext($form, cfg);
                            var targetPage = resolveTargetPage(cfg.targetPage, formData);
                            parseWikitext(wikitext, targetPage).then(function (html) {
                                $previewArea.html(html);
                            });
                        });
                    } else { // live
                        $form.append($previewArea);
                        var updateFormPreview = debounce(function () {
                            var formData   = collectFormData($form, cfg);
                            var wikitext   = buildWikitext($form, cfg);
                            var targetPage = resolveTargetPage(cfg.targetPage, formData);
                            parseWikitext(wikitext, targetPage).then(function (html) {
                                $previewArea.html(html);
                            });
                        }, 500);
                        // listen to *all* inputs in the form
                        $form.on('input change', 'input, textarea, select', updateFormPreview);
                        updateFormPreview(); // initial render (includes defaults)
                    }
                }

                $form.on('submit', function (e) {
                    e.preventDefault();
                    submit($form, cfg, $submit);
                });
            });
        }

        /* ---------- insert question or static block ------------------ */
        function insertItem($form, q) {
            var safeName = q._fieldName;   // always defined for non‑static items

            switch (q.type) {
                case 'heading':
                    $form.append($('<h3>').addClass('fa-heading').text(q.text));
                    return;
                case 'static':
                case 'html':
                    var $ph = $('<div class="formassistant-placeholder fa-static"></div>');
                    $form.append($ph); // preserves ordering
                    parseWikitext(q.html || q.text || '', q.formPage)
                        .then(function (html) {
                            // Ensure final output retains styling class
                            $ph.replaceWith($(html).addClass('fa-static'));
                        });
                    return;
            }

            var $wrapper = $('<div>').addClass('fa-question');

            var $label = $('<label>')
                .addClass('fa-question-label')
                .text(q.label + (q.required ? ' (required)' : ''));
            var $field;

            switch (q.type) {
                case 'text':
                    $field = $('<input>').attr({ type: 'text', name: safeName, size: 40, value: q.default || '' });
                    break;
                case 'textarea':
                    $field = $('<textarea>').attr({ name: safeName, rows: 4, cols: 60 }).val(q.default || '');
                    break;
                case 'dropdown':
                    $field = $('<select>').attr('name', safeName);
                    (q.options || []).forEach(function (opt) {
                        var $o = $('<option>').val(opt).text(opt);
                        if (opt === q.default) $o.prop('selected', true);
                        $field.append($o);
                    });
                    break;
                case 'checkbox':
                    $field = $('<span>');
                    var defs = Array.isArray(q.default) ? q.default : (q.default ? [q.default] : []);
                    // Add initial line break for vertical layout
                    if (q.vertical) {
                        $field.append('<br>');
                    }
                    (q.options || []).forEach(function (opt) {
                        var $l = $('<label>');
                        var $cb = $('<input>').attr({ type: 'checkbox', name: safeName, value: opt });
                        if (defs.includes(opt)) $cb.prop('checked', true);
                        $l.append($cb, ' ', opt);
                        // Add line break if vertical layout is requested
                        if (q.vertical) {
                            $l.append('<br>');
                        } else {
                            $l.append('\u00A0'); // non-breaking space for horizontal layout
                        }
                        $field.append($l);
                    });
                    break;
                case 'radio':
                    $field = $('<span>');
                    (q.options || []).forEach(function (opt) {
                        var $l = $('<label>');
                        var $rb = $('<input>').attr({ type: 'radio', name: safeName, value: opt });
                        if (q.required) $rb.attr('required', true);
                        if (opt === q.default) $rb.prop('checked', true);
                        $l.append($rb, ' ', opt, '\u00A0');
                        $field.append($l);
                    });
                    break;
                default:
                    console.warn('[form-assistant.js] Unsupported field type:', q.type);
                    return;
            }

            /* ------------ accessibility annotations --------------- */
            var fieldId = safeName + '_input';
            $field.attr('id', fieldId);
            $label.attr('for', fieldId);
            if (q.required) {
                $field.attr({ required: true, 'aria-required': 'true' });
            }
            if (['checkbox', 'radio'].includes(q.type)) {
                $field.attr({ role: 'group', 'aria-labelledby': fieldId + '_lbl' });
                $label.attr('id', fieldId + '_lbl');
            }

            $wrapper.append($label, ' ', $field.addClass('fa-question-input'));

            /* ---------- per‑question live preview ---------------- */
            var qPrevMode = normalizePreviewMode(q.preview);

            if (qPrevMode !== 'none' && ['text', 'textarea'].includes(q.type)) {
                var $qPrev = $('<div>')
                    .addClass('fa-field-preview')
                    .css({ border: '1px solid #c8ccd1', padding: '4px', marginTop: '4px' });

                var updateFieldPreview = debounce(function () {
                    var val = ($field.val() || '').trim();
                    if (!val) { $qPrev.empty(); return; }
                    parseWikitext(val, q.formPage).then(function (html) { $qPrev.html(html); });
                }, 500);

                if (qPrevMode === 'live') {
                    $field.on('input', updateFieldPreview);
                    updateFieldPreview(); // initial render
                    $wrapper.append($qPrev);
                } else { // button
                    var $btnPrev = $('<button>')
                        .addClass('mw-ui-button fa-q-preview-btn')
                        .attr('type', 'button')
                        .text('Preview');
                    $btnPrev.on('click', updateFieldPreview);
                    $wrapper.append(' ', $btnPrev, $qPrev);
                }
            }

            $form.append($wrapper);
        }

        /* ---------- helper: resolve target page variables ----------- */
        function resolveTargetPage(targetPage, formData) {
            if (!targetPage || typeof targetPage !== 'string') return targetPage;
            
            // Replace {{USERNAME}} with current user
            var resolved = targetPage.replace(/\{\{USERNAME\}\}/g, mw.config.get('wgUserName') || '');
            
            // Replace {{FIELD:fieldname}} with form field values
            resolved = resolved.replace(/\{\{FIELD:([^}]+)\}\}/g, function(match, fieldName) {
                return formData[fieldName] || match;
            });
            
            return resolved;
        }

        /* ---------- 3. Submission ------------------------------------ */
        function valueOf($form, q) {
            var sel = '[name="' + q._fieldName + '"]';
            switch (q.type) {
                case 'checkbox':
                    return $form.find(sel + ':checked').map(function () { return this.value; }).get().join(', ');
                case 'radio':
                    return $form.find(sel + ':checked').val() || '';
                default:
                    return ($form.find(sel).val() || '').trim();
            }
        }

        /* ---------- helper: collect form data ----------------------- */
        function collectFormData($form, cfg) {
            var data = {};
            (cfg.questions || []).forEach(function (q) {
                if (q.templateParam) data[q.templateParam] = valueOf($form, q);
            });
            return data;
        }

        function submit($form, cfg, $submit) {
            // Collect all form data for target page resolution
            var formData = collectFormData($form, cfg);

            // Custom validation for required checkbox groups
            var missing = (cfg.questions || []).filter(function (q) {
                if (!q.required) return false;
                var val = valueOf($form, q);
                return !val; // empty string means nothing selected
            });

            if (missing.length) {
                alert('Please complete required fields: ' + missing.map(function (q) { return q.label; }).join(', '));
                return;
            }

            var wikitext = buildWikitext($form, cfg);

            // Resolve target page with variables
            var targetPage = resolveTargetPage(cfg.targetPage, formData);

            $submit.prop('disabled', true).val('Submitting…');
            
            // Determine edit parameters based on prepend option
            var editParams = {
                action: 'edit',
                title: targetPage,
                summary: cfg.editSummary || 'Post answers via [[Mediawiki:form-assistant.js|form-assistant.js]]'
            };
            
            if (cfg.prepend) {
                editParams.prependtext = wikitext;
            } else {
                editParams.appendtext = wikitext;
            }
            
            api.postWithToken('csrf', editParams).done(function () {
                /* ---------- post‑submit action ------------------ */
                function replaceFormWithMessage() {
                    // Clear entire content area and inject parsed message
                    var $content = $('#mw-content-text').empty();
                    parseWikitext(cfg.onComplete.html || cfg.onComplete.text || '', cfg.formPage)
                        .then(function (html) { $content.append($(html)); });
                }

                if (cfg.onComplete) {
                    // 1. Simple string → redirect
                    if (typeof cfg.onComplete === 'string') {
                        window.location.href = mw.util.getUrl(cfg.onComplete);
                        return;
                    }
                    // 2. Explicit redirect object
                    if (cfg.onComplete.redirectPage) {
                        window.location.href = mw.util.getUrl(cfg.onComplete.redirectPage);
                        return;
                    }
                    // 3. Static/html message
                    if (cfg.onComplete.text || cfg.onComplete.html) {
                        replaceFormWithMessage();
                        return;
                    }
                }

                // Default behaviour if no onComplete directive
                mw.notify('Saved!', { type: 'success' });
                $form[0].reset();
            }).fail(function (err) {
                console.error('[formFiller.js] Edit error:', err);
                mw.notify('Error: ' + err, { type: 'error', autoHide: false });
            }).always(function () {
                $submit.prop('disabled', false).val('Submit');
            });
        }
    });
})();