<style>
[data-template] {
display: none;
}
[data-filtered-out-by] {
display: none;
}
</style>
<script type="text/javascript">
"use strict";
if (window.ajaxHelpers) {
throw Error('Ajax helpers loaded multiple times.');
}
/*
* maps toast types to their html classes
*/
const toastClasses = {
ERROR: 'ajax-toast-error',
SUCCESS: 'ajax-toast-success'
};
/*
* Reads the data attributes
*/
const readDataAttributes = function (elem) {
const outmap = {};
const attrs = elem.attributes;
for (let i = 0; i < attrs.length; i++) {
outmap[attrs[i].name] = attrs[i].value;
}
return outmap;
};
/*
* Converts from a snake_case string to regex that matches _MACRO_CASE
* Importantly, the regex will have three capture groups, one just before the match, the match, and one just after
* e.g. varName -> _VAR_NAME
*/
const toVarReg = function (str) {
const reg = '\\b_' + camelToSnake(str).toUpperCase() + '\\b';
return new RegExp(reg, 'g');
};
/*
* Projections that can be called from data-use or before actions are taken
* e.g. <input type="checkbox" data-on-change="..." data-use="checked|toBool" />
* e.g. <input type="checkbox" data-on-ajax-success="toSuccessToast|addToast" />
*/
const projections = {};
projections.not = (val) => !val;
projections.toBool = function (val) {
const res = val === "on" || val === "true" || val === 1 || val === true;
return res;
};
projections.toValue = (inputEvent) => inputEvent.target.value;
projections.toSetUnset = function (val) {
if (typeof val !== 'boolean') {
val = projections.toBool(val);
}
if (val) {
return "set";
}
return "unset";
};
projections.falseyToNullString = (str) => str || 'null';
projections.toData = function (val) {
if (typeof val === 'string') {
return val;
}
if (typeof val === 'object') {
if (val.length != null) {
// Typical jQuery response (data, textStatus, request) or (request, textStatus, errorThrown)
if (typeof val[2] === 'string' && val[2]) {
return val[2];
}
if (typeof val[0] === 'string' && val[0]) {
return val[0];
}
}
}
return null;
};
projections.toHeaderNotice = function (val) {
if (typeof val === 'object' && val.length != null) {
let xhr;
if (val[0] && val[0].getResponseHeader) {
xhr = val[0];
} else if (val[2] && val[2].getResponseHeader) {
xhr = val[2];
}
if (xhr != null && xhr.getResponseHeader('X-Notice')) {
return xhr.getResponseHeader('X-Notice');
}
}
return null;
};
projections.toMessage = function (val) {
if (typeof val === 'string') {
return val;
}
const hNotice = projections.toHeaderNotice(val);
if (hNotice != null) {
return hNotice;
}
if (typeof val === 'object') {
if (val.length != null) {
// Typical jQuery response (data, textStatus, request) or (request, textStatus, errorThrown)
if (typeof val[0] === 'object'
&& val[0].responseText) {
return val[0].responseText;
}
if (typeof val[2] === 'string' && val[2]) {
return val[2];
}
if (typeof val[0] === 'string' && val[0]) {
return val[0];
}
if (val[1] === 'error') {
return 'Error: Unable to connect to server.';
}
if (typeof val[1] === 'string' && val[1]) {
return val[1];
}
}
/* Here we could further expand on our standard success/error message format.
E.g.
if (val[0].message) { val.message; }
if (val[0].success) { "Success"; }
if (val[0].error) { "Error"; }
*/
}
return null; // Null/undefined messages will get ignored
}
projections.toErrorToast = function (val) {
let message = projections.toMessage(val);
if (message == null) {
message = 'Error';
}
return {
message: message,
className: toastClasses.ERROR
};
}
projections.toSuccessToast = function (val) {
let message = projections.toMessage(val);
if (message == null) {
message = 'Success';
}
return {
message: message,
className: toastClasses.SUCCESS
};
}
projections.toStyledToast = function (val) {
// If is ajax response
let non200 = false;
if (typeof val === 'object' && val[0] != null && val[0].status != null) {
non200 = val[0].status < 200 || val[0].status >= 300;
}
let message = projections.toMessage(val);
if (message == null) {
return null;
} // Pass nulls through
let className = '';
const lowercase = message.toLowerCase();
if (non200 || lowercase.startsWith('error') || lowercase.startsWith('internal server error')) {
className = toastClasses.ERROR;
} else if (lowercase.startsWith('success')) {
className = toastClasses.SUCCESS;
}
return {
message: message,
className: className
};
}
projections.encodeURI = function (val) {
return encodeURIComponent(val);
};
const applyProjections = function (inVal, selectedProjections) {
return selectedProjections.reduce(function (val, projection) {
if (!(projection in projections)) {
throw Error('Invalid data- projection: '
+ projection
+ '. Available projections: '
+ Object.keys(projections).join(', ')
);
}
// Run the value through the projection
return projections[projection](val);
}, inVal);
};
{# /*
* Convert a kebab-case string to a camelCase one
* e.g. class-name -> className
* inner-html -> innerHTML
* namespace-uri -> namespaceURI
*/ #}
const kebabToCamel = function (str) {
const acronyms = ['html', 'uri'];
const parts = str.split('-');
const tail = parts.slice(1).map(function (t) {
if (t === '') {
return t;
}
if (t in acronyms) {
return t.toUpperCase();
}
return t.charAt(0).toUpperCase() + t.slice(1);
});
return parts[0] + tail.join('');
};
{# /*
* Convert a camelCase string to a kebab-case one
* e.g. className -> class-name
* innerHTML -> inner-html
* namespaceURI -> namespace-uri
*/ #}
const camelToKebab = function (str) {
return str.replace(/([a-z][A-Z])/g, function (_match, p1) {
return p1.charAt(0) + '-' + p1.charAt(1);
}).toLowerCase();
};
{# /*
* Converts kebab-case strings to snake_case ones
* e.g. class-name -> class_name
*/ #}
const kebabToSnake = function (str) {
return str.replace(/\-/g, '_');
}
{# /*
* Convert a camelCase string to a snake_case one
* e.g. className -> class_name
* innerHTML -> inner_html
* namespaceURI -> namespace_uri
*/ #}
const camelToSnake = function (str) {
return kebabToSnake(camelToKebab(str));
};
{# /*
* Convert all keys of an objects keys to another case
* e.g. { className: 'someVal' } -> { class_name: 'someVal' }
*/ #}
const convertObjCase = function (obj, conversion) {
return Object.keys(obj).reduce(function (newObj, key) {
newObj[conversion(key)] = obj[key];
return newObj;
}, {});
};
const parseAction = function (actionNames, actions, attr) {
const reducedActions = actionNames.split(',').reduce(function (projectedActions, actionName) {
actionName = actionName.trim();
if (!actionName) {
return projectedActions;
}
const parts = actionName.split('|');
const name = parts[parts.length - 1]; // Select the last one
const action = actions[name]
if (!action) {
console.error('Error in data-on-: action "' + name + '" doesn\'t exist! Available actions: ' + Object.keys(actions));
return projectedActions;
}
const projections = parts.slice(0, -1);
let usableAttr = attr;
if (!usableAttr) {
usableAttr = 'data-for-' + camelToKebab(name);
}
const newProjectedAction = function (e, elem, ctx, eventHandlers) {
const attrs = readDataAttributes(elem);
const primaryArg = attrs[usableAttr];
const updatedPrimary = applyProjections(primaryArg || e, projections);
const newPrimaryArg = actionArgs(name, elem, updatedPrimary, attrs);
action(newPrimaryArg, elem, ctx, eventHandlers);
};
// Check if we should create a toast element
return projectedActions.concat(newProjectedAction);
}, []);
// Return the object
return function (primaryArg, elem, ctx, eventHandlers) {
reducedActions.forEach(function (action) {
action(primaryArg, elem, ctx, eventHandlers);
});
};
}
{# /*
* Extract data from attributes and element to send to server
* If you want to request the url https://example.com/add/234
* You can use the element attributes:
* data-on-change-get="https://example.com/_ID"
* data-add-id="234"
* Similarly you can use "data-use" to extract something from the element itself:
* data-on-change-get="https://example.com/_PROP/_VALUE"
* data-use="value"
* data-use-as-prop="innerHTML"
* Finally, if the context is of type form, then the inputs will be read (name / value)
* and used as a basis for the context
*/ #}
const getContextData = function (elem) {
const attrs = readDataAttributes(elem);
let initialContext = {}
if (elem.tagName === 'FORM') {
initialContext = $(elem) // Select the form
.serializeArray() // Serialize inputs
.reduce((iCtx, nameVal) => {
iCtx[nameVal.name] = nameVal.value;
return iCtx;
}, {}); // Convert array to obj
}
return Object.keys(attrs).reduce(function (data, key) {
if (key.indexOf('data-use') === 0) {
const propParts = attrs[key].split('|');
const prop = propParts[0]; // property name
const selProjections = propParts.slice(1); // selected projections
let varName = prop;
if (key.indexOf('data-use-as-') === 0) { // Use an alias
varName = key.replace(/^data\-use\-as\-/, '');
varName = kebabToCamel(varName);
} else if (key !== 'data-use') {
throw Error('Invalid data-use property: ' + key);
}
if (!(prop in elem)) {
throw Error('Invalid data-use value: ' + prop);
}
// Apply all projections
const val = applyProjections(elem[prop], selProjections);
return writeVar(data, varName, val);
}
if (key.indexOf('data-add-') === 0) {
let varName = key.replace(/^data\-add\-/, '');
const value = attrs[key];
varName = kebabToCamel(varName);
return writeVar(data, varName, value);
}
return data;
}, initialContext);
};
const writeVar = (data, varName, val) => {
const locationChain = varName.split(/[\[\]]/).filter((t) => t);
const updatedData = Object.assign({}, data);
let currentLevel = updatedData;
locationChain.forEach((name, index) => {
if (index === locationChain.length-1) {
currentLevel[name] = val;
return;
}
if (!currentLevel[name]) {
currentLevel[name]={};
} else {
currentLevel[name]=Object.assign({}, currentLevel[name]);
}
currentLevel = currentLevel[name];
});
return updatedData;
}
/*
* "Enhance" the URL by replacing parts of the URL
* If you want to request the URL https://example.com/add/234
* You can used the context: { id: 234 }, and URL: https://example.com/add/_ID
*/
const formatURL = function (url, data) {
return Object.keys(data).reduce(function (url, key) {
let val = data[key];
if (typeof val !== 'string') {
val = JSON.stringify(val);
}
const newURL = url.replace(toVarReg(key), encodeURIComponent(val));
return newURL;
}, url);
}
/*
* Instantiate a template by replacing parameters in the URL
* If you want the html: <div>Your ID is: 123</div>
* You can used the context: { id: 123 }, and template string: "<div>Your ID is: _ID</div>"
*/
const formatHTML = function (html, data) {
return Object.keys(data).reduce(function (html, key) {
let val = data[key];
if (typeof val !== 'string') {
val = JSON.stringify(val);
}
const newHTML = html.replace(toVarReg(key), val);
return newHTML;
}, html);
}
{# /*
* Prepares the argument(s) to be passed to actions
* When calling actions, often all you need is a single argument and some context
* However, sometimes you need more than one argument.
* This can be accomplished by adding more properties.
* E.g. data-for-create-elem-to="..." data-for-create-elem-insert-by=""
*/ #}
const actionArgs = function (actionName, elem, primaryValue, attrs) {
const dataFor = 'data-for-' + camelToKebab(actionName) + '-';
const dataForLen = dataFor.split('-').length - 1;
const otherKeys = Object.keys(attrs)
.filter((t) => t.startsWith('data-for-'))
.map((t) => {
if (t.startsWith(dataFor)) {
return {attr: t, rep: dataFor};
}
// Allow for * matches
const matchStart = t.split('-').slice(0, dataForLen).join('-');
const regex = new RegExp(matchStart.replaceAll('*', '[\\w|-|\\*]*') + '-?');
return {attr: t, rep: regex};
})
.filter((t) => dataFor.match(t.rep));
if (otherKeys.length === 0) {
return primaryValue;
}
const args = {0: primaryValue};
for (let i = 0; i < otherKeys.length; i++) {
const attrRep = otherKeys[i];
const shortName = attrRep.attr.replace(attrRep.rep, '');
const camelName = shortName === '' ? 0 : kebabToCamel(shortName);
args[camelName] = attrs[attrRep.attr];
}
if (Object.keys(args).length === 1 && args[0] != null) {
return args[0];
}
return args;
};
{# /*
* Creates an event handler for secondary events (e.g. response, ajax-success, ajax-error).
* It returns a function that receives one (or more) arguments,
* and wraps it with the other arguments provided.
* This allows us to bind to an event like:
* $.get(...).done(secondaryHandler(actionName, elem, _ctx, _eventHandlers))
*/ #}
const secondaryHandler = function (actionName, elem, ctx, eventHandlers) {
return function () {
const action = eventHandlers[actionName];
if (!action) {
return;
}
let wrappedVal = arguments;
if (arguments.length <= 1) {
wrappedVal = arguments[0];
}
return action(wrappedVal, elem, ctx, eventHandlers);
};
}
const getChangeClassOptions = function (arg, elem, actionName) {
if (typeof arg === 'string') {
if (!arg) {
throw new Error('Invalid action ' + actionName + ', class name is empty!');
}
return {operateOn: elem, className: arg};
}
if (arg.select && !$(arg.select)[0]) {
throw new Error('Invalid action '
+ actionName
+ ', selected element (from data-for-*-select: '
+ arg.select
+ ') doesn\'t exist!');
}
return {
operateOn: arg.select || elem,
className: arg[0]
};
}
{# /*
* Virtual Filter - Decide what should be filtered without modifying the DOM
* returns attributes to be set or removed
*/ #}
const filterAttr = 'data-filtered-out-by';
const vFilter = function (options, elem) {
if (typeof options !== 'object') {
throw Error('Invalid filter action. No named arguments (' + options + ') specified. Did you forget a "data-for-filter-by" attribute?');
}
const filterValue = options[0] || options.value;
const name = options.name || 'general';
const select = options.select || $(elem).children();
const by = options.by;
if (!by && options.byText == null) {
throw Error('Invalid filter action. No filterBy specified. Did you forget a "data-for-filter-by" attribute?');
}
const totalList = $(select);
const startVisibleLength = totalList.toArray().filter(t => !t.hasAttribute(filterAttr)).length;
// The filter is being cleared
if (!filterValue) {
const setRemove = totalList.toArray().reduce((setRem, item) => {
const names = item.getAttribute(filterAttr);
if (names == null) {
return setRem;
}
const newNames = names.split(',').filter((t) => t !== name);
if (newNames.length === 0) {
setRem.remove = setRem.remove.concat({attr: filterAttr, item: item});
return setRem;
}
setRem.set = setRem.set.concat({attr: filterAttr, item: item, value: newNames.join(','), isNew: false});
return setRem;
}, {remove: [], set: []});
return {
totalLength: totalList.length,
setRemove: setRemove,
startVisibleLength: startVisibleLength,
};
}
// The filter should be added or removed
let match = 'like';
const matchFunctions = {
startsWith: (q, v) => v.startsWith(q),
like: (q, v) => v.includes(q),
exact: (q, v) => q === v,
gt: (q, v) => q > v,
gte: (q, v) => q >= v,
lt: (q, v) => q < v,
lte: (q, v) => q <= v
};
const matchOptions = {
like: options.like,
exact: options.exact,
gt: options.gt,
gte: options.gte,
lt: options.lt,
lte: options.lte,
startsWith: options.startsWith
};
const activeMatchOptions = Object.keys(matchOptions).filter((t) => matchOptions[t] != null);
if (activeMatchOptions.length > 1) {
throw Error('Invalid filter action. Conflicting match options (' + activeMatchOptions.join(', ') + ')');
}
if (activeMatchOptions.length === 1) {
match = activeMatchOptions[0];
}
const lowerFilterValue = options.asInt != null
? parseInt(filterValue, 10)
: filterValue.toLowerCase();
const setRemove = totalList.toArray().reduce((setRem, item) => {
let itemVal;
if (options.byText == null) {
const itemCtx = getContextData(item);
if (itemCtx[by] == null) {
throw Error('Filter error. Item (' + item.outerHTML + ') is missing data context attribute ' + by + '. Did you forget a "data-add-" attribute?');
}
itemVal = itemCtx[by].toLowerCase();
} else {
itemVal = $(item).text().toLowerCase();
}
const names = item.getAttribute(filterAttr);
if (!matchFunctions[match](lowerFilterValue, itemVal)) {
const isNew = names == null;
const oldNames = isNew ? [] : names.split(',');
if (oldNames.includes(name)) {
return setRem;
}
const newNames = oldNames.concat(name);
setRem.set = setRem.set.concat({attr: filterAttr, item: item, value: newNames.join(','), isNew: isNew});
return setRem;
} else {
if (names == null) {
return setRem;
}
const newNames = names.split(',').filter((t) => t !== name);
if (newNames.length === 0) {
setRem.remove = setRem.remove.concat({attr: filterAttr, item: item});
return setRem;
}
setRem.set = setRem.set.concat({attr: filterAttr, item: item, value: newNames.join(','), isNew: false});
return setRem;
}
}, {set: [], remove: []});
return {
totalLength: totalList.length,
setRemove: setRemove,
startVisibleLength: startVisibleLength,
};
};
// Primary HTML Events we can listen for
const events = ['change', 'click', 'mouseover', 'mouseenter', 'mouseout', 'input', 'submit'];
{# /*
* Actions we can take
* Each action must handle both it's primary duty (e.g. a get request),
* and calling secondary event handlers (e.g. the on response event)
*/ #}
const actions = {};
actions.call = function (arg, elem, ctx, eventHandlers) {
if (typeof arg !== 'string') {
throw Error('No function specified for action call (' + JSON.stringify(arg) + '). Did you forget a "data-for-call" attribute?');
}
arg.split(',').forEach((arg) => {
const funcName = arg.trim();
if (!funcName) {
return;
}
// Call globally defined function
window[funcName]({arg: arg, elem: elem});
});
};
actions.confirm = function (arg, elem, ctx, eventHandlers) {
// Confirm is synchronous
if (confirm(arg)) {
secondaryHandler('confirmed', elem, ctx, eventHandlers)(arg);
return;
}
// Else
secondaryHandler('not-confirmed', elem, ctx, eventHandlers)(arg);
};
actions.consoleLog = function (arg, elem, ctx, eventHandlers) {
console.log('Log', {
arg: arg,
elem: elem,
ctx: ctx,
eventHandlers: eventHandlers
});
};
const debounceTimers = {}; // These are global
actions.debounce = function (args, elem, ctx, eventHandlers) {
let name = 'default';
let time = 1500; // default to 1.5s
if (typeof args === 'object') {
name = args.name || elem.getAttribute('id') || elem.getAttribute('name') || name;
time = args.time || time;
}
clearTimeout(debounceTimers[name]);
debounceTimers[name] = setTimeout(secondaryHandler('debounced', elem, ctx, eventHandlers), time);
};
actions.disableInput = function(args, elem, ctx, eventHandlers) {
let select = args;
if (typeof select !== 'string') {
if (inputTagNames.includes(elem.tagName) || ['BUTTON', 'SUBMIT'].includes(elem.tagName)) {
select = elem;
} else {
throw Error('Invalid action disableInput. No selector specified. Did you forget a data-for-disable-input?');
}
}
$(select).prop('disabled', true);
};
actions.enableInput = function(args, elem, ctx, eventHandlers) {
let select = args;
if (typeof select !== 'string') {
if (inputTagNames.includes(elem.tagName) || ['BUTTON', 'SUBMIT'].includes(elem.tagName)) {
select = elem;
} else {
throw Error('Invalid action enableInput. No selector specified. Did you forget a data-for-enable-input attribute?');
}
}
$(select).prop('disabled', false);
};
actions.get = function (args, elem, ctx, eventHandlers) {
if (!args) {
throw Error('No arguments found for ajax get. Did you forget a "data-for-get" attribute?');
}
const url = typeof args === 'string' ? args : (args[0] || args.url);
if (!url) {
throw Error('No URL found for ajax get. Did you forget a "data-for-get" attribute?');
}
const accept = args.accept || 'text/plain';
$.ajax({
method: 'get',
url: formatURL(url, ctx),
headers: {Accept: accept}
}).done(secondaryHandler('ajax-success', elem, ctx, eventHandlers))
.fail(secondaryHandler('ajax-error', elem, ctx, eventHandlers))
.always(secondaryHandler('response', elem, ctx, eventHandlers));
};
actions.post = function (args, elem, ctx, eventHandlers) {
if (!args) {
throw Error('No arguments found for ajax post. Did you forget a "data-for-post" attribute?');
}
const url = typeof args === 'string' ? args : (args[0] || args.url);
if (!url) {
throw Error('No URL found for ajax post. Did you forget a "data-for-post" attribute?');
}
const accept = args.accept || 'text/plain';
const reqData = ctx;
$.ajax({
method: 'POST',
url: url,
data: reqData,
headers: {Accept: accept}
}).done(secondaryHandler('ajax-success', elem, ctx, eventHandlers))
.fail(secondaryHandler('ajax-error', elem, ctx, eventHandlers))
.always(secondaryHandler('response', elem, ctx, eventHandlers));
};
actions.toggleModal = function(args, elem, _ctx, _eventHandlers) {
let modal = elem;
if (args && typeof args === 'string') {
modal = args;
}
$(args).modal('toggle');
};
actions.showModal = function(args, elem, _ctx, _eventHandlers) {
let modal = elem;
if (args && typeof args === 'string') {
modal = args;
}
$(args).modal('show');
};
actions.hideModal = function(args, elem, _ctx, _eventHandlers) {
let modal = elem;
if (args && typeof args === 'string') {
modal = args;
}
$(args).modal('hide');
};
actions.toggleClass = function (args, elem, _ctx, _eventHandlers) {
const options = getChangeClassOptions(args, elem, 'toggleClass');
$(options.operateOn).toggleClass(options.className);
};
actions.addClass = function (args, elem, _ctx, _eventHandlers) {
const options = getChangeClassOptions(args, elem, 'addClass');
$(options.operateOn).addClass(options.className);
};
actions.removeClass = function (args, elem, _ctx, _eventHandlers) {
const options = getChangeClassOptions(args, elem, 'removeClass');
$(options.operateOn).removeClass(options.className);
};
actions.addToast = function (message, elem, _ctx, _eventHandlers) {
if (!message) {
return;
}
addToast(message);
};
actions.removeEl = function (selector, elem, _ctx, _eventHandlers) {
$(selector || elem).remove();
};
actions.createEl = function (toFrom, elem, ctx, _eventHandlers) {
let to;
let from;
let id;
let template;
if (typeof toFrom === "string") {
from = $(toFrom);
} else {
from = $(toFrom.from || toFrom[0]);
to = toFrom.to;
id = toFrom.id;
template = toFrom.template;
}
if ((!from || from.length == 0) && !template) {
throw Error('No template found for createEl. Did you forget a "data-for-create-el" attribute?');
}
// It's possible that data-for-create-el-from is set
// and not data-for-create-el-to
if (!to || to.length == 0) {
if (from) {
to = $(from).parent(); // Default to parent of template
} else {
to = $(elem).parent(); // Default to parent of calling
}
}
// Read Template
const html = template || from.html();
if (!html.trim()) {
throw Error('No template found for createEl. Targeted element is empty!');
}
const formattedElements = $.parseHTML(formatHTML(html, ctx));
// We can insert in a particular place
const insertBy = toFrom.insertBy || toFrom.byInv;
if (toFrom.insertFirst != null) {
if (insertBy) {
throw Error('Invalid data-for(s) for createEl. You cannot specify both "insert-first" and "insert-by".');
}
$(to).prepend(formattedElements);
return;
}
if (insertBy) {
// Search for an element
const valueToInsert = ctx[insertBy];
if (valueToInsert == null) {
throw Error('Insert by property (' + insertBy + ') isn\'t present in the current context! Did you for get a data-add or data-use?');
}
const children = $(to).children().toArray();
// If the insertion is inverted
let comp = function (a, b) {
return a > b;
};
if (toFrom.insertByInv) {
comp = function (a, b) {
return a < b;
};
}
const insertBefore = children.find((elem) => {
const elemCtx = getContextData(elem);
// This comparison should (mostly) work for strings and numbers.
return elemCtx[toFrom.insertBy] != null && comp(elemCtx[toFrom.insertBy].toLowerCase(), valueToInsert.toLowerCase());
});
// If we find a place to insert, insert it
if (insertBefore != null) {
$(formattedElements).id = id;
$(formattedElements).insertBefore(insertBefore);
return;
}
}
// Insert at end of target's children
$(to).append(formattedElements);
};
actions.updateEl = function (selected, elem, ctx, _eventHandlers) {
selected = selected || elem;
if ($(selected).length === 0) {
throw Error('Invalid updateEl action. Element to update (' + selected + ') not found.');
}
selected = $(selected);
const template = selected.attr('data-render-template');
if (template == null || $(template).length === 0) {
throw Error(
'Invalid updateEl action. Template (' + template + ') not found. '
+ 'Does this element have a "data-render-template" attribute?'
);
}
const html = $(template).html().trim();
const oldContext = getContextData(selected[0]);
const newContext = Object.assign(oldContext, ctx);
// Render to the target
const formattedHTML = formatHTML(html, newContext)
const formattedElements = $.parseHTML(formattedHTML);
// Update the context / attributes
// As long as we're rending to the same typeof object, we can just update it
if (selected[0].type === formattedElements[0].type) {
const oldAttrs = readDataAttributes(selected[0]);
const newAttrs = readDataAttributes(formattedElements[0]);
selected[0].innerHTML = formattedElements[0].innerHTML;
// Remove Old
Object.keys(oldAttrs).forEach(function (key) {
if (newAttrs[key] != null) {
return;
}
// These keys are held over
if (key === 'id' || key === 'data-render-template') {
return;
}
selected.removeAttr(key);
});
// Add new
Object.keys(newAttrs).forEach(function (key) {
selected.attr(key, newAttrs[key]);
});
} else { // Here we actually just replace the element
selected[0].outerHTML = formattedHTML;
}
};
actions.freshEl = function (args, elem, ctx, _eventHandlers) {
let responseOrHTML = args;
if (args[0] != null && args.length == null && typeof args !== 'string') {
responseOrHTML = args[0];
}
const html = projections.toData(responseOrHTML);
const createArgs = Object.assign(args, {template: html});
actions.createEl(createArgs, elem, ctx, _eventHandlers);
};
actions.refreshEl = function (args, elem, ctx, _eventHandlers) {
let responseOrHTML = args;
let target = elem;
if (args.select != null) {
responseOrHTML = args[0];
target = $(args.select)[0];
}
const html = projections.toData(responseOrHTML);
target.outerHTML = html;
};
actions.grayOut = function (sel, elem, ctx, _eventHandlers) {
let target = elem;
if (typeof sel === 'string') {
target = $(sel)[0] || elem;
}
$(target).addClass('grayout');
$('input, textarea, select', target).prop('disabled', true);
$('label, [role="button"]', target).addClass('disabled');
};
actions.filter = function (options, elem, ctx, _eventHandlers) {
// Get info on what should be modified
const filterData = vFilter(options, elem).setRemove;
// Set the attributes
filterData.set.forEach((s) => {
s.item.setAttribute(s.attr, s.value);
});
// Remove the attributes
filterData.remove.forEach((r) => {
r.item.removeAttribute(r.attr);
});
};
actions.sendForm = function (arg, elem, ctx, eventHandlers) {
let options = {};
if (arg != null) {
if (arg instanceof Event) {
arg.preventDefault();
} else if (arg[0] instanceof Event && !arg.allowDefault) {
arg.preventDefault();
options = arg;
}
}
const method = (options.method || elem.getAttribute('method') || 'get').toLowerCase();
const action = options.action || elem.getAttribute('action') || window.location.href;
if (method === 'get') {
actions.get(action, elem, ctx, eventHandlers);
} else {
actions.post(action, elem, ctx, eventHandlers);
}
// Call secondary event handler for submit
secondaryHandler('form-sent', elem, ctx, eventHandlers)(arg);
};
actions.validateForm = function (arg, elem, ctx, eventHandlers) {
let target = elem;
if (arg != null) {
if (arg instanceof Event) {
arg.preventDefault();
} else if (arg[0] instanceof Event && !arg.allowDefault) {
arg.preventDefault();
}
if (typeof arg === 'string') {
target = $(arg)[0];
} else if (arg.select != null) {
target = $(arg.select)[0];
}
}
if (target.checkValidity()) {
secondaryHandler('valid', elem, ctx, eventHandlers)(arg);
return;
}
secondaryHandler('invalid', elem, ctx, eventHandlers)(arg);
};
actions.showFormValidation = function (arg, elem, ctx, eventHandlers) {
let target = $(elem);
if (arg != null) {
if (typeof arg === 'string') {
target = $(arg)[0];
} else if (arg.select != null) {
target = $(arg.select)[0];
}
}
$(target).addClass('was-validated');
};
actions.resetForm = function (arg, elem, ctx, eventHandlers) {
let target = elem;
if (arg != null) {
if (typeof arg === 'string') {
target = $(arg)[0];
} else if (arg.select != null) {
target = $(arg.select)[0];
}
}
if (!target || !target.reset) {
throw Error('Invalid resetForm action. Target element ' + (target && target.tagName) + ' is not a form and cannot be reset.');
}
target.reset();
$(target).removeClass('was-validated');
}
actions.fireTrigger = function (arg, elem, ctx, eventHandlers) {
const name = arg;
const triggerActions = triggers[name];
if (!triggerActions) {
throw Error('Invalid trigger action. Trigger (' + name + ') not found in: ' + Object.keys(triggers).join(', '));
}
for(let i = 0; i < triggerActions.length; i++) {
const ta= triggerActions[i];
const context = getContextData(ta.elem);
ta.action(null, ta.elem, context, ta.moreEvents);
}
}
let triggers = {};
{# /*
* Here is the code for actually showing toasts on screen
*/ #}
const toastQueues = {}; // Keep track of our queues
const displayToastFor = 5000; // Five seconds
const nextToasts = function (next) {
if (next.queue.length === 0) { // Begin hiding the toast
return;
}
let message = next.queue.shift();
let className = '';
if (typeof message === 'object') {
className = message.className || '';
message = message.message;
}
next.slots--;
// create toast container on the fly
if (!toastContainer) { createToastContainer(); }
// Set message and show
const newToast = createToastElement(message, className);
$(newToast).css('opacity', 0);
$(newToast).css('display', 'block');
toastContainer.appendChild(newToast);
setTimeout(() => {
$(newToast).css('opacity', 1);
}, 100);
setTimeout(function () {
$(newToast).css('opacity', 0);
setTimeout(function () {
toastContainer.removeChild(newToast);
}, 500); // transition time
next.slots++;
nextToasts(next);
}, displayToastFor);
};
const addToast = function (message) {
let queueName = 'info';
if (typeof message === 'object' && message.className) {
queueName = message.className;
}
if (!toastQueues[queueName]) {
toastQueues[queueName] = {
queue: [],
slots: 3,
};
}
const next = toastQueues[queueName];
next.queue.push(message);
// Only start if it isn't already processing enough
if (next.slots > 0) {
nextToasts(next);
}
};
let toastContainer = null;
const createToastContainer = function () {
if (toastContainer != null) {
return;
}
toastContainer = document.createElement('div');
toastContainer.setAttribute('class', 'ajax-toast-container');
document.body.appendChild(toastContainer);
};
const createToastElement = function (message, className) {
const el = document.createElement('div');
el.setAttribute('role', 'alert');
el.setAttribute('aria-live', 'assertive');
el.setAttribute('aria-atomic', 'true');
el.setAttribute('class', 'ajax-toast ' + className);
el.innerHTML = message;
return el;
};
{#
// As action x events need to be searched for to be bound,
// and searching for them might cause some performance issues,
// only use these shortcuts, the rest can use normal syntax
#}
const commonPrimaryActions = [
{event: 'click', attr: 'data-on-click-confirm', act: 'confirm'},
{event: 'click', attr: 'data-on-click-get', act: 'get'},
{event: 'click', attr: 'data-on-click-post', act: 'post'},
{event: 'click', attr: 'data-on-click-add-class', act: 'addClass'},
{event: 'click', attr: 'data-on-click-remove-class', act: 'removeClass'},
{event: 'click', attr: 'data-on-click-toggle-class', act: 'toggleClass'},
{event: 'click', attr: 'data-on-click-show-modal', act: 'showModal'},
{event: 'click', attr: 'data-on-click-hide-modal', act: 'hideModal'},
{event: 'click', attr: 'data-on-click-toggle-modal', act: 'toggleModal'},
{event: 'click', attr: 'data-on-click-create-el', act: 'createEl'},
{event: 'click', attr: 'data-on-click-update-el', act: 'updateEl'},
{event: 'click', attr: 'data-on-click-fire-trigger', act: 'fireTrigger'},
{event: 'change', attr: 'data-on-change-get', act: 'get'},
{event: 'change', attr: 'data-on-change-post', act: 'post'},
{event: 'change', attr: 'data-on-change-toggle-class', act: 'toggleClass'},
{event: 'change', attr: 'data-on-change-add-class', act: 'addClass'},
{event: 'change', attr: 'data-on-change-remove-class', act: 'removeClass'},
{event: 'change', attr: 'data-on-change-create-el', act: 'createEl'},
{event: 'change', attr: 'data-on-change-update-el', act: 'updateEl'},
{event: 'change', attr: 'data-on-change-fire-trigger', act: 'fireTrigger'},
{event: 'submit', attr: 'data-on-submit-confirm', act: 'confirm'},
];
const commonSecondaryActions = [
{event: 'response', attr: 'data-on-response-update-el', act: 'updateEl'},
{event: 'response', attr: 'data-on-response-toast', act: 'toStyledToast|addToast'},
{event: 'response', attr: 'data-on-response-header-toast', act: 'toHeaderNotice|toStyledToast|addToast'},
{event: 'ajax-success', attr: 'data-on-ajax-success-fresh-el', act: 'freshEl'},
{event: 'ajax-success', attr: 'data-on-ajax-success-refresh-el', act: 'refreshEl'},
{event: 'ajax-success', attr: 'data-on-ajax-success-fire-trigger', act: 'fireTrigger'},
{event: 'ajax-fail', attr: 'data-on-ajax-fail-toast', act: 'toErrorToast|addToast'},
];
// Events that can happen as a result of something else
const secondaryEvents = ['response', 'ajax-success', 'ajax-error', 'form-sent', 'debounced', 'confirmed', 'not-confirmed', 'valid', 'invalid'];
const allSecondaryActions = commonSecondaryActions.concat(secondaryEvents.map(function (eventName) {
return {event: eventName, attr: 'data-on-' + eventName, act: null};
}));
{# /*
* Parses the secondary events from the attributes and turns them
* map a functions that can be called
* e.g. data-on-response="get" -> { 'on-response': function(...) { ... } }
*/ #}
const getSecondaryEvents = function (elem) {
const attrs = readDataAttributes(elem);
return allSecondaryActions.reduce(function (eventMap, eventActionPair) {
if (attrs[eventActionPair.attr] == null) {
return eventMap;
}
const eventName = eventActionPair.event;
let actionNames = eventActionPair.act;
// If not a shortcut
if (!actionNames) {
actionNames = attrs[eventActionPair.attr];
}
if (!actionNames) {
return eventMap;
}
const parsed = parseAction(actionNames, actions, eventActionPair.act && eventActionPair.attr);
let newAction = parsed;
const oldAction = eventMap[eventName];
if (oldAction) {
newAction = (e, elem, ctx, eventHandlers) => {
parsed(e, elem, ctx, eventHandlers);
oldAction(e, elem, ctx, eventHandlers);
};
}
eventMap[eventName] = newAction;
return eventMap;
}, {});
};
const attrActionEvents = commonPrimaryActions.concat(
events.map(function (e) {
// Formal def (e.g. data-on-click="get" data-for-get="...")
return {event: e, attr: 'data-on-' + e, act: null};
})
);
const primarySel = attrActionEvents.map(function (t) {
return '[' + t.attr + ']';
}).concat('[data-ajax]').join(', ');
let mutationObserver;
const bindToDOMChanges = function (onNodesAdded) {
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
// Options for the observer (which mutations to observe)
const config = {attributes: false, childList: true, subtree: true};
// Callback function to execute when mutations are observed
const callback = function (mutationsList, observer) {
let changed = [];
for (let i = 0; i < mutationsList.length; i++) {
const mutation = mutationsList[i];
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
changed = changed.concat(
$(mutation.addedNodes).add(primarySel, mutation.addedNodes).toArray()
);
}
}
// Remove duplicates
changed = changed.filter(function (elem, index, self) {
return self.indexOf(elem) === index;
});
onNodesAdded(changed);
};
// Create an observer instance linked to the callback function
mutationObserver = new MutationObserver(callback);
// Start observing the target node for configured mutations
mutationObserver.observe(document, config);
};
const inputTagNames = ['TEXTAREA', 'INPUT', 'SELECT']
const bindToEvents = function (elements) {
// By default don't create the toast element
elements = $(elements);
elements.each(function (_index, elem) {
// We're only able to bind to elements
if (elem.nodeType !== Node.ELEMENT_NODE) {
return;
}
const moreEvents = getSecondaryEvents(elem);
// Iterate through action-event pairings, find elements, and bind to them
for (let i = 0; i < attrActionEvents.length; i++) {
const attrActEv = attrActionEvents[i];
// If the element doesn't have the attribute, it's a pass
if (!elem.hasAttribute(attrActEv.attr)) {
continue;
}
const eventName = attrActEv.event;
const attrName = attrActEv.attr;
let actionNames = attrActEv.act;
// If this is a "normal" binding (e.g. data-on-change="post"), lookup the action name
if (actionNames == null) {
actionNames = elem.getAttribute(attrName);
}
// Action not found
if (actionNames == null) {
console.error("No action found to call when binding to event: " + attrName, elem);
continue;
}
const parsedAction = parseAction(actionNames, actions, attrActEv.act && attrName);
// If someone tries to listen to changes in an empty <span>, error
// If someone tries to listen to changes in a <span> with <inputs> inside,
// bind to each input
let elems = $(elem);
let formContext = false;
if (['change', 'input'].includes(eventName) && !inputTagNames.includes(elem.tagName)) {
elems = $(inputTagNames.join(', '), elem);
if (elems.length === 0) {
console.error('Attempted to bind to input event:',
eventName + '.',
'With an element (',
elem.tagName,
') that isn\'t an input, and does\'t have any child inputs.',
elem);
return;
}
formContext = true;
}
// We might listen to more than one element
elems.each(function (_in, childElem) {
// Bind to a primary event - add an event listener
childElem.addEventListener(eventName, function (e) {
let context = getContextData(elem);
// If we've bound to a different element that was included in the attribute
// Apply that child's properties on top of ours
if (childElem !== elem) {
const addContext = getContextData(childElem);
context = Object.assign(context, addContext);
}
parsedAction(e, elem, context, moreEvents);
});
// By default remove browser cached values on input
if ((inputTagNames.includes(childElem.tagName))
&& !childElem.hasAttribute('data-no-reset')
&& !elem.hasAttribute('data-no-reset')) {
switch (childElem.tagName) {
case 'SELECT':
let index = $('option', childElem).toArray().findIndex((t) => t.hasAttribute('selected'));
if (index < 0) {
index = null;
}
childElem.selectedIndex = index;
break;
default:
if (['checkbox', 'radio'].includes(childElem.type)) {
childElem.checked = childElem.hasAttribute('checked');
} else {
childElem.value = childElem.getAttribute('value');
}
break;
}
}
});
if ('FORM' === elem.tagName && !elem.hasAttribute('data-no-reset')) {
elem.reset();
}
}
// Bind to triggers, they have 'dynamic' names
const attrs = readDataAttributes(elem);
const newTriggers = Object.keys(attrs)
.filter((name) => name.startsWith('data-on-trigger-'))
.map((attrName) => {
const parsed = parseAction(elem.getAttribute(attrName), actions, attrName);
const name = attrName.replace(/^data\-on\-trigger\-?/, '') || 'default';
return {
name: name,
action: parsed,
elem: elem,
moreEvents: moreEvents
};
});
for (let i = 0; i < newTriggers.length; i++) {
const trigger = newTriggers[i];
const actionList = triggers[trigger.name] || [];
triggers[trigger.name] = actionList.concat(trigger);
}
});
};
// Bind to events in the whole document
bindToEvents($(primarySel));
// When there's additions to the DOM, bind to those too
bindToDOMChanges(bindToEvents);
// Export to allow testing/debugging
window.ajaxHelpers = {
kebabToSnake: kebabToSnake,
kebabToCamel: kebabToCamel,
camelToKebab: camelToKebab,
bindToEvents: bindToEvents,
toastContainer: toastContainer,
applyProjections: applyProjections,
actionArgs: actionArgs,
vFilter: vFilter,
filterAttr: filterAttr,
projections: projections,
formatURL: formatURL,
formatHTML: formatHTML,
getContextData: getContextData,
readDataAttributes: readDataAttributes,
createToastContainer: createToastContainer,
createToastElement: createToastElement,
addToast: addToast,
toastClasses: toastClasses,
triggers: triggers
};
// Can be used to post changes to single fields to the database. Made to post single fields like in allmeda_pages_set_toggle_ajax_with_data, also compatible with any ajax responding paths.
function ajaxPostUpdate(pathUrl, id, field, action, sendToast = true) {
$.ajax({
url: pathUrl,
method: 'Post',
data: {
'id' : id,
'field' : field,
'action' : action,
},
headers: { 'Accept': 'application/json, text/plain' }
}).done((data, textStatus, request) => {
// Typical jQuery response (data, textStatus, request)
if(sendToast) addToast(projections.toStyledToast([data, textStatus, request]));
if (data && data.success && data.redirect) {
window.location.href = data.redirect;
}
})
.fail((request, textStatus, errorThrown) => {
// Typical jQuery response (request, textStatus, errorThrown)
if(sendToast) addToast(projections.toStyledToast([request, textStatus, errorThrown]));
})
if(sendToast) createToastContainer();
}
</script>