MediaWiki:Gadget-SelektConfigurator.js
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));