MediaWiki:Gadget-SelektConfigurator.js

From LinnDocs
Jump to: navigation, search

Note: After saving, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
/**
 * Selekt DSM output configurator
 *
 * Draws one Cartridge and one DAC drop-down per slot plus a Validate button,
 * and checks the chosen configuration against a rules file.
 *
 * All product knowledge (slots, options, rules, messages) lives in the rules
 * file. This script only knows the four generic rule types below, so the rules
 * can be changed without touching code.
 *
 *   requires       Per slot: if the slot matches "if", it must also match "then".
 *   noMix          Whole unit: values from groupA and groupB can't both appear.
 *   count          Whole unit: how many slots may hold matching values
 *                  ("allowed": [list], and/or "min" / "max").
 *   ifAnyThenSlot  Whole unit: if any slot matches "ifAny", slot N must match "then".
 *
 * A value spec is "any" (anything except "none"), "none", or a list of values.
 *
 * Written as ES5 so MediaWiki's ResourceLoader accepts it as a classic gadget.
 */
(function (root) {
  'use strict';

  var RULE_TYPES = ['requires', 'noMix', 'count', 'ifAnyThenSlot'];

  /* ---------- helpers ---------- */

  function isArray(x) { return Object.prototype.toString.call(x) === '[object Array]'; }
  function has(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); }
  function keys(obj) { var k = []; for (var p in obj) { if (has(obj, p)) { k.push(p); } } return k; }

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) { node.className = className; }
    if (text != null) { node.textContent = text; } // never innerHTML: rules text is editor-supplied
    return node;
  }

  function matches(value, spec) {
    if (spec === 'any') { return value !== 'none'; }
    if (spec === 'none') { return value === 'none'; }
    if (isArray(spec)) { return spec.indexOf(value) !== -1; }
    return value === spec;
  }

  function matchesAll(slotState, cond) {
    var f = keys(cond || {});
    for (var i = 0; i < f.length; i++) {
      if (!matches(slotState[f[i]], cond[f[i]])) { return false; }
    }
    return true;
  }

  function optionLabel(config, field, value) {
    var opts = (config.fields[field] && config.fields[field].options) || [];
    for (var i = 0; i < opts.length; i++) { if (opts[i].value === value) { return opts[i].label; } }
    return value;
  }

  function slotIndexById(config, id) {
    for (var i = 0; i < config.slots.length; i++) { if (config.slots[i].id === id) { return i; } }
    return -1;
  }

  /* Fill {slot}, {cartridge}, {dac} (or any field name) in a message. */
  function fillMessage(message, config, state, slotIndex) {
    return String(message || '').replace(/\{(\w+)\}/g, function (whole, key) {
      if (slotIndex == null) { return whole; }
      if (key === 'slot') { return config.slots[slotIndex].label; }
      if (has(config.fields, key)) { return optionLabel(config, key, state[slotIndex][key]); }
      return whole;
    });
  }

  /* ---------- rules engine ---------- */

  function validate(config, state) {
    var results = [];

    (config.rules || []).forEach(function (rule) {
      if (rule.enabled === false) { return; }
      var severity = rule.severity === 'warning' ? 'warning' : 'error';
      function report(slotIndexes, slotForMessage) {
        results.push({
          id: rule.id,
          severity: severity,
          slots: slotIndexes,
          message: fillMessage(rule.message, config, state, slotForMessage)
        });
      }

      switch (rule.type) {
        case 'requires':
          state.forEach(function (slot, i) {
            if (matchesAll(slot, rule['if']) && !matchesAll(slot, rule.then)) { report([i], i); }
          });
          break;

        case 'noMix':
          var a = [], b = [];
          state.forEach(function (slot, i) {
            if (matches(slot[rule.field], rule.groupA)) { a.push(i); }
            if (matches(slot[rule.field], rule.groupB)) { b.push(i); }
          });
          if (a.length && b.length) { report(a.concat(b), null); }
          break;

        case 'count':
          var hits = [];
          state.forEach(function (slot, i) { if (matches(slot[rule.field], rule.values)) { hits.push(i); } });
          var n = hits.length, ok = true;
          if (isArray(rule.allowed) && rule.allowed.indexOf(n) === -1) { ok = false; }
          if (typeof rule.min === 'number' && n < rule.min) { ok = false; }
          if (typeof rule.max === 'number' && n > rule.max) { ok = false; }
          if (!ok) { report(hits, null); }
          break;

        case 'ifAnyThenSlot':
          var target = slotIndexById(config, rule.slot);
          var triggered = state.some(function (slot) { return matchesAll(slot, rule.ifAny); });
          if (triggered && target > -1 && !matchesAll(state[target], rule.then)) { report([target], target); }
          break;
      }
    });

    return results;
  }

  /* Check the rules file itself, so typos are caught before they silently never match. */
  function checkRules(config) {
    var problems = [];
    if (!config || typeof config !== 'object') { return ['The rules file is empty or not a JSON object.']; }
    if (!isArray(config.slots) || !config.slots.length) { problems.push('"slots" must be a list with at least one slot.'); }
    if (!config.fields || !keys(config.fields).length) { problems.push('"fields" must define at least one drop-down.'); }
    if (problems.length) { return problems; }

    function checkSpec(field, spec, where) {
      if (!has(config.fields, field)) { problems.push(where + ': unknown field "' + field + '".'); return; }
      if (spec === 'any' || spec === 'none') { return; }
      var list = isArray(spec) ? spec : [spec];
      var valid = config.fields[field].options.map(function (o) { return o.value; });
      list.forEach(function (v) {
        if (valid.indexOf(v) === -1) { problems.push(where + ': "' + v + '" is not an option of ' + field + '.'); }
      });
    }
    function checkCond(cond, where) {
      if (!cond || typeof cond !== 'object') { problems.push(where + ': missing condition.'); return; }
      keys(cond).forEach(function (f) { checkSpec(f, cond[f], where); });
    }

    var seen = {};
    (config.rules || []).forEach(function (rule, i) {
      var where = 'Rule ' + (rule.id ? '"' + rule.id + '"' : '#' + (i + 1));
      if (!rule.id) { problems.push(where + ': add an "id" so the rule can be identified.'); }
      else if (seen[rule.id]) { problems.push(where + ': id is used more than once.'); }
      seen[rule.id] = true;
      if (!rule.message) { problems.push(where + ': add a "message" to show the user.'); }
      if (RULE_TYPES.indexOf(rule.type) === -1) {
        problems.push(where + ': unknown type "' + rule.type + '". Use one of: ' + RULE_TYPES.join(', ') + '.');
        return;
      }
      if (rule.type === 'requires') { checkCond(rule['if'], where + ' "if"'); checkCond(rule.then, where + ' "then"'); }
      if (rule.type === 'noMix') { checkSpec(rule.field, rule.groupA, where + ' "groupA"'); checkSpec(rule.field, rule.groupB, where + ' "groupB"'); }
      if (rule.type === 'count') { checkSpec(rule.field, rule.values, where + ' "values"'); }
      if (rule.type === 'ifAnyThenSlot') {
        checkCond(rule.ifAny, where + ' "ifAny"');
        checkCond(rule.then, where + ' "then"');
        if (slotIndexById(config, rule.slot) === -1) { problems.push(where + ': slot ' + rule.slot + ' does not exist.'); }
      }
    });
    return problems;
  }

  /* ---------- UI ---------- */

  function mount(container, config) {
    container.textContent = '';
    var root = el('div', 'selekt-cfg');
    var uid = 'selekt-cfg-' + Math.random().toString(36).slice(2, 8);
    var fieldNames = keys(config.fields || {});

    var problems = checkRules(config);
    if (problems.length) {
      var box = el('div', 'selekt-cfg__rules-problems');
      box.appendChild(el('p', 'selekt-cfg__rules-problems-title', 'The rules file has problems. Affected rules may not work:'));
      var ul = el('ul');
      problems.forEach(function (p) { ul.appendChild(el('li', null, p)); });
      box.appendChild(ul);
      root.appendChild(box);
      if (!isArray(config.slots) || !fieldNames.length) { container.appendChild(root); return; }
    }

    var panel = el('div', 'selekt-cfg__panel');
    var bays = [], selects = [];

    config.slots.forEach(function (slot, i) {
      var bay = el('fieldset', 'selekt-cfg__bay');
      bay.appendChild(el('legend', 'selekt-cfg__bay-name', slot.label));
      var status = el('span', 'selekt-cfg__bay-status');
      bay.appendChild(status);
      selects[i] = {};

      fieldNames.forEach(function (field) {
        var id = uid + '-' + i + '-' + field;
        var label = el('label', 'selekt-cfg__label', config.fields[field].label);
        label.setAttribute('for', id);
        var select = el('select', 'selekt-cfg__select');
        select.id = id;
        config.fields[field].options.forEach(function (o) {
          var opt = el('option', null, o.label);
          opt.value = o.value;
          select.appendChild(opt);
        });
        select.addEventListener('change', markStale);
        bay.appendChild(label);
        bay.appendChild(select);
        selects[i][field] = select;
      });

      bays[i] = { node: bay, status: status };
      panel.appendChild(bay);
    });

    var actions = el('div', 'selekt-cfg__actions');
    var validateBtn = el('button', 'selekt-cfg__btn selekt-cfg__btn--primary', 'Validate configuration');
    validateBtn.type = 'button';
    var resetBtn = el('button', 'selekt-cfg__btn', 'Reset');
    resetBtn.type = 'button';
    actions.appendChild(validateBtn);
    actions.appendChild(resetBtn);

    var results = el('div', 'selekt-cfg__results');
    results.setAttribute('aria-live', 'polite');

    root.appendChild(panel);
    root.appendChild(actions);
    root.appendChild(results);
    container.appendChild(root);

    function readState() {
      return selects.map(function (s) {
        var o = {};
        fieldNames.forEach(function (f) { o[f] = s[f].value; });
        return o;
      });
    }

    function setBay(i, state) {
      var b = bays[i];
      b.node.className = 'selekt-cfg__bay' + (state ? ' is-' + state : '');
      b.status.textContent = state === 'error' ? 'Problem' : state === 'warning' ? 'Check' : state === 'ok' ? 'OK' : '';
    }

    function markStale() {
      bays.forEach(function (_, i) { setBay(i, null); });
      if (results.firstChild) {
        results.textContent = '';
        results.className = 'selekt-cfg__results';
        results.appendChild(el('p', 'selekt-cfg__stale', 'Selection changed. Validate again to check it.'));
      }
    }

    function run() {
      var state = readState();
      var found = validate(config, state);
      var errors = found.filter(function (r) { return r.severity === 'error'; });
      var warnings = found.filter(function (r) { return r.severity === 'warning'; });

      bays.forEach(function (_, i) {
        var level = 'ok';
        found.forEach(function (r) {
          if (r.slots.indexOf(i) !== -1) { level = (r.severity === 'error' || level === 'error') ? 'error' : 'warning'; }
        });
        setBay(i, level);
      });

      results.textContent = '';
      var kind = errors.length ? 'error' : warnings.length ? 'warning' : 'ok';
      results.className = 'selekt-cfg__results is-' + kind;
      var headline = errors.length
        ? 'This configuration won\u2019t work (' + errors.length + ' problem' + (errors.length > 1 ? 's' : '') + ')'
        : warnings.length ? 'This configuration is valid, with a note' : 'This configuration is valid';
      results.appendChild(el('p', 'selekt-cfg__headline', headline));

      if (found.length) {
        var list = el('ul', 'selekt-cfg__list');
        errors.concat(warnings).forEach(function (r) {
          var li = el('li', 'selekt-cfg__item is-' + r.severity, r.message);
          li.setAttribute('data-rule', r.id || '');
          list.appendChild(li);
        });
        results.appendChild(list);
      }
    }

    validateBtn.addEventListener('click', run);
    resetBtn.addEventListener('click', function () {
      selects.forEach(function (s) { fieldNames.forEach(function (f) { s[f].selectedIndex = 0; }); });
      bays.forEach(function (_, i) { setBay(i, null); });
      results.textContent = '';
      results.className = 'selekt-cfg__results';
    });
  }

  function showLoadError(container, message) {
    container.textContent = '';
    var box = el('div', 'selekt-cfg__rules-problems');
    box.appendChild(el('p', 'selekt-cfg__rules-problems-title', 'The Selekt configurator could not start.'));
    box.appendChild(el('p', null, message));
    container.appendChild(box);
  }

  root.SelektConfigurator = { mount: mount, validate: validate, checkRules: checkRules };

  /* ---------- MediaWiki loader ----------
   * On any wiki page containing:
   *   <div class="selekt-configurator" data-rules="Selekt DSM Configurator/rules.json"></div>
   * fetch the rules page as raw JSON and mount the configurator.
   */
  if (root.mw && root.mw.hook) {
    root.mw.hook('wikipage.content').add(function ($content) {
      $content.find('.selekt-configurator').each(function () {
        var container = this;
        if (container.getAttribute('data-mounted')) { return; }
        container.setAttribute('data-mounted', '1');
        var page = container.getAttribute('data-rules');
        if (!page) { showLoadError(container, 'Add data-rules="Name of rules page" to the configurator div.'); return; }
        var url = root.mw.util.getUrl(page, { action: 'raw', ctype: 'application/json', maxage: 0, smaxage: 0 });
        root.fetch(url, { credentials: 'same-origin' })
          .then(function (r) {
            if (!r.ok) { throw new Error('Could not load the rules page "' + page + '" (HTTP ' + r.status + ').'); }
            return r.text();
          })
          .then(function (text) {
            var config;
            try { config = JSON.parse(text); }
            catch (e) { throw new Error('The rules page "' + page + '" is not valid JSON: ' + e.message); }
            mount(container, config);
          })
          ['catch'](function (e) { showLoadError(container, e.message); });
      });
    });
  }
}(typeof window !== 'undefined' ? window : this));