Skip to content

Instantly share code, notes, and snippets.

@FabianSchmick
Last active September 30, 2019 13:42
Show Gist options
  • Save FabianSchmick/8220f77a1087f31f72a92f4f1afb49d1 to your computer and use it in GitHub Desktop.
Save FabianSchmick/8220f77a1087f31f72a92f4f1afb49d1 to your computer and use it in GitHub Desktop.
Javascript SPA
import { addDisabled, removeDisabled } from '../../util/disable';
/**
* Available options:
*
* options = {
* settings = {
* type: 'POST' || null, // Type of request or [data-ajax-method] - default GET
* url: '/script' || null, // Url for the request or form[action] or a[href] - default current url
* data: data || [] // Additional data to be send or serialized form - default []
* },
*
* config = {
* callback: function() || null // Custom callback function(resultData, ajaxInstance) - default null
* disable: element || [elements] || false, // Set attribute disabled on certain element/s with [data-ajax-disable] - default event element
* updateUrl: true || false, // Updates the Url (history.pushState) with [data-ajax-update-url] - default false
* preventDefault: true || false // Prevent event from default behaviour with [data-ajax-prevent-default]- default true
* }
* }
*/
export class Ajax {
constructor(el = null, ev = null, options = {}) {
this.el = el;
this.e = ev;
this.settings = {};
this.config = {};
const settings = options.settings || {};
const config = options.config || {};
// Default settings https://api.jquery.com/jquery.ajax/
this.settings = {
type: $(this.el).attr('method') || $(this.el).data('ajax-method') || 'GET',
url: $(this.el).attr('action') || $(this.el).attr('href') || window.location.href,
data: []
};
// Set data to serialized form data
if (!settings.data && this.el && $(this.el).prop('tagName').toLowerCase() === 'form') {
this.settings.data = $(this.el).serialize();
}
// Extend new settings object if form is a mulitpart form
if ($(this.el).attr('enctype') === 'multipart/form-data') {
this.settings = Object.assign(this.settings, {
data: new FormData($(this.el)[0]),
processData: false,
contentType: false,
cache: false,
enctype: 'multipart/form-data'
});
}
// Override settings defaults
this.settings = Object.assign(this.settings, settings);
// Behaviour and handling of the request
this.config = {
disable: $(this.el).data('ajax-disable'),
updateUrl: $(this.el).data('ajax-update-url') || false,
callback: null,
preventDefault: $(this.el).data('ajax-prevent-default') || true
};
if (this.config.disable !== false && this.el && $(this.el).prop('tagName').toLowerCase() === 'form') { // Disable for form submit button
this.config.disable = $(this.el).find('button[type="submit"]');
} else if (this.config.disable !== false) { // Disable for element (anchor or button)
this.config.disable = this.el || false;
}
// Override config defaults
this.config = Object.assign(this.config, config);
if (this.config.preventDefault) {
this.e.preventDefault();
this.e.stopPropagation();
}
}
execute() {
addDisabled(this.config.disable);
$.ajax(this.settings)
.done((data, textStatus, jqXHR) => {
// Redirect if header: App-Ajax-Redirect is set
if (jqXHR.getResponseHeader('App-Ajax-Redirect')) {
location.href = jqXHR.getResponseHeader('App-Ajax-Redirect');
return;
}
this.handleResult(data);
}).fail((XMLHttpRequest, textStatus, errorThrown) => {
console.log(XMLHttpRequest, textStatus, errorThrown);
}).always(() => {
removeDisabled(this.config.disable);
$('body').removeClass('cursor-loading');
})
;
}
/** Handles the result data from the ajax request */
handleResult(data) {
if (this.config.updateUrl) {
history.pushState(null, '', this.settings.url);
}
this.callback(data);
}
/** Execute custom callback with result of ajax and current instance */
callback(data) {
if (this.config.callback !== undefined && this.config.callback) {
this.config.callback(data, this);
}
}
}
const ajax = _ => {
const $body = $('body');
$body.on('click', 'a[data-ajax="spa"], button[data-ajax="spa"]', function (e) {
e.preventDefault();
import('../modules/Ajax/SinglePageAjax').then(({ SinglePageAjax }) => {
let ajax = new SinglePageAjax(this, e);
ajax.execute();
}).catch(e => console.error(e));
}).on('submit', 'form[data-ajax="spa"]', function (e) {
e.preventDefault();
import('../modules/Ajax/SinglePageAjax').then(({ SinglePageAjax }) => {
let ajax = new SinglePageAjax(this, e);
ajax.execute();
}).catch(e => console.error(e));
});
};
export { ajax };
const removeDisabled = (el) => {
if (el) {
if (typeof el[Symbol.iterator] === 'function') {
$(el).each(function () {
$(this).removeClass('disabled');
$(this).prop('disabled', false);
});
} else {
$(el).removeClass('disabled');
$(el).prop('disabled', false);
}
}
};
const addDisabled = (el) => {
if (el) {
if (typeof el[Symbol.iterator] === 'function') {
$(el).each(function () {
$(this).addClass('disabled');
$(this).prop('disabled', true);
});
} else {
$(el).addClass('disabled');
$(el).prop('disabled', true);
}
}
};
export { removeDisabled, addDisabled };
{% extends 'base.html.twig' %}
{% import _self as macro %}
{%- macro create_ajax_data_attr(data, attr) -%}
{%- spaceless -%}
{%- for key,attr in data -%}
{{ key }}='{{attr|raw}}'{{ " " }}
{%- endfor -%}
{%- endspaceless -%}
{%- endmacro -%}
{% block content %}
<div class="container mb-5">
<h1 class="mt-3">Ajax testing</h1>
<hr>
<section id="test-1">
<h4><b>section#test-1</b>: Simple <kbd>GET</kbd> request</h4>
{%-
set ajax = {
'data-ajax': 'spa',
'data-ajax-mapping': 'section#test-1'
}
-%}
<pre><code>
{
data-ajax: 'spa',
data-ajax-mapping: 'section#test-1'
}
</code></pre>
<a class="btn btn-primary" href="{{ path('test_ajax_test_results') }}" {{ macro.create_ajax_data_attr(ajax) }}>Test</a>
</section>
<section id="test-2">
<hr class="mt-4">
<h4><b>section#test-2</b>: Mode and more mappings</h4>
{%-
set ajax = {
'data-ajax': 'spa',
'data-ajax-mode': 'append',
'data-ajax-mapping': {
'section#test-1': 'section#test-1 .alert-success',
'section#test-2': 'section#test-2 .alert-success'
}|json_encode
}
-%}
<pre><code>
{
data-ajax: 'spa',
data-ajax-mode: 'prepend',
data-ajax-mapping: {
'section#test-1': 'section#test-1 .alert-success',
'section#test-2': 'section#test-2 .alert-success'
}
}
</code></pre>
<a class="btn btn-primary" href="{{ path('test_ajax_test_results') }}" {{ macro.create_ajax_data_attr(ajax) }}>Test</a>
</section>
<section id="test-3">
<hr class="mt-4">
<h4><b>section#test-3</b>: Remove the element after the <kbd>POST</kbd> request and modal</h4>
{%-
set ajax = {
'data-ajax': 'spa',
'data-ajax-method': 'POST',
'data-ajax-mapping': {
'section#test-3': 'null'
}|json_encode,
'data-modal': 'true'
}
-%}
<pre><code>
{
data-ajax: 'spa',
data-ajax-method: 'POST',
data-ajax-mapping: {
'section#test-3': 'null'
},
data-modal: true
}
</code></pre>
<a class="btn btn-primary" href="{{ path('test_ajax_test_results') }}" {{ macro.create_ajax_data_attr(ajax) }}>Test</a>
</section>
<section id="test-4">
<hr class="mt-4">
<h4><b>section#test-4</b>: Form <small>(method and url are handled via form attributes)</small></h4>
{%-
set ajax = {
'data-ajax': 'spa',
'data-ajax-mapping': {
'section#test-4 form': 'section#test-4 .result'
}|json_encode
}
-%}
<pre><code>
{
data-ajax: 'spa',
data-ajax-mapping: {
'section#test-4 form': 'section#test-4 .result'
}
}
</code></pre>
<form method="POST" action="{{ path('test_ajax_test_results') }}" {{ macro.create_ajax_data_attr(ajax) }}>
<label for="test-4-input">Name</label>
<input id="test-4-input" type="text" name="test-input" value="John Doe">
<button class="btn btn-primary" type="submit">Test</button>
</form>
</section>
</div>
{% endblock %}
import { removeDisabled } from './disable';
/**
* Available options:
*
* options = {
* header: 'text', // Text for the headline or [data-modal-header]
* body: 'text', // Text for the body or [data-modal-body]
* cancel: 'text' // Text for the close / cancel button or [data-modal-cancel]
* ok: 'text', // Text for the ok button or [data-modal-ok]
* id: 'id', // The Id for the modal or [data-modal-id]
* class: 'class', // Additional class for the modal or [data-modal-class]
* callback: function() || null, // A function to be executed after clicked modals ok button
* container: element // A container for the modal element or [data-modal-container]
* }
*/
export class Modal {
constructor(element = null, event = null, options = {}) {
this.el = element;
this.e = event;
this.options = {
header: $(this.el).data('modal-header') || 'Modal header',
body: $(this.el).data('modal-body') || 'Modal body',
cancel: $(this.el).data('modal-cancel') || 'Cancel',
ok: $(this.el).data('modal-ok') || 'Confirm',
id: $(this.el).data('modal-id') || 'modal-' + Math.floor(Math.random() * 100),
class: $(this.el).data('modal-class') || '',
callback: null,
container: $(this.el).data('modal-container') || $('#site-modals'),
};
// Override settings defaults
this.options = Object.assign(this.options, options);
}
show() {
$(this.el).data('modal-id', this.options.id);
let id = '#' + this.options.id;
if ($(id).length === 0) {
$(this.options.container).append(this.getHtml());
}
$(id).modal('show').on('hide.bs.modal', () => {
removeDisabled(this.el);
});
$(id).find('[data-modal-ok]').on('click', () => {
$(this.el).data('modal-confirmed', 'true');
this.hide();
this.callback();
});
}
hide() {
$('#' + this.options.id).modal('hide');
}
callback() {
if (this.options.callback !== undefined && this.options.callback) {
this.options.callback(this);
}
}
getHtml() {
return `
<div id="${this.options.id}" class="modal ${this.options.class}" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${this.options.header}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="${this.options.cancel}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>${this.options.body}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn on-light-bg" data-dismiss="modal">${this.options.cancel}</button>
<button type="button" class="btn on-light-bg submit" data-modal-ok>${this.options.ok}</button>
</div>
</div>
</div>
</div>
`;
}
}
{% extends 'base.html.twig' %}
{% import _self as macro %}
{%- macro create_ajax_data_attr(data, attr) -%}
{%- spaceless -%}
{%- for key,attr in data -%}
{{ key }}='{{attr|raw}}'{{ " " }}
{%- endfor -%}
{%- endspaceless -%}
{%- endmacro -%}
{% block content %}
<div class="container mb-5">
<h1 class="mt-3">Ajax testing results</h1>
<hr>
<section id="test-1">
<hr class="mt-4">
<h4><b>section#test-1</b>: Simple <kbd>GET</kbd> request</h4>
<div class="alert-success">Success for Test 1</div>
</section>
<section id="test-2">
<hr class="mt-4">
<h4><b>section#test-2</b>: Mode and more mappings</h4>
<div class="alert-success">Success for Test 2</div>
</section>
<section id="test-4">
<hr class="mt-4">
<h2><b>section#test-4</b> Mode and more mappings</h2>
<div class="result">
{% if app.request.get('test-input') %}
<strong>Hello: {{ app.request.get('test-input') }}</strong>
{% endif %}
</div>
</section>
</div>
{% endblock %}
import { addDisabled } from '../../util/disable';
import { Modal } from '../../util/modal';
import { Ajax } from './Ajax';
/**
* Available options:
*
* options = {
* config = {
* mapping: element || { // Single element which is source and target at once
* sourceElement: targetElement, // Or an array of mappings: key => source : value => target
* furtherSource: furtherTarget, // configure with [data-ajax-mapping]
* furtherSource: 'null' // Element gets removed
* } || null, // Default null (nothing gets updated)
* mode: 'replace' || 'append' || 'prepend', // Mode how to handle mappings with [data-ajax-mode] - default replace
* }
* }
*/
export class SinglePageAjax extends Ajax {
constructor(el = null, ev = null, options = {}) {
super(el, ev, options);
const config = options.config || {};
// Behaviour and handling of the request
this.config.mapping = config.mapping || $(this.el).data('ajax-mapping') || null;
this.config.mode = config.mode || $(this.el).data('ajax-mode') || 'replace';
}
execute() {
addDisabled(this.config.disable);
if ($(this.el).data('modal') && !$(this.el).data('modal-confirmed')) {
let modal = new Modal(this.el, this.e, {
callback: () => {
let ajax = new SinglePageAjax(this.el, this.e);
ajax.execute();
}
});
modal.show();
return;
}
super.execute();
}
handleResult(data) {
// Filter finds node on the root level but not on levels below
let scriptsRoot = $(data).filter('script');
$(data).filter('script').remove();
// Find does not find nodes on the root level but on levels below
let scriptsNested = $(data).find('script');
$(data).find('script').remove();
let html = $.parseHTML($.trim(data));
let scripts = $.merge(scriptsRoot, scriptsNested);
let mapping = this.config.mapping;
if (mapping) {
if (typeof mapping === 'string') {
this.handleMapping(mapping, mapping, html);
} else if (typeof mapping === 'object') {
for (let source in mapping) {
if (mapping.hasOwnProperty(source)) {
this.handleMapping(source, mapping[source], html);
}
}
}
}
// Only execute scripts if a partial html was returned
// otherwise all the main.js scripts would be included and executed again
// see https://stackoverflow.com/questions/14423257/find-body-tag-in-an-ajax-html-response why we search with string methods
if (data.indexOf('</body>') === -1) {
$(scripts).each(function () {
$('body').append(this);
});
}
super.handleResult(data);
}
/** @internal Use of handling the html result from the ajax request */
handleMapping(source, target, html) {
if (target === 'null') {
$(source).fadeOut(500, function () { $(this).remove(); });
return;
}
let mode = this.config.mode,
targetHtml = $(html).find(target);
if ($(source).length > 1) {
$(source).each(function(index) {
if (mode === 'prepend') {
$(this).prepend(targetHtml.get(index));
} else if (mode === 'append') {
$(this).append(targetHtml.get(index));
} else {
$(this).replaceWith(targetHtml.get(index));
}
});
} else {
if (mode === 'prepend') {
$(source).prepend(targetHtml);
} else if (mode === 'append') {
$(source).append(targetHtml);
} else {
$(source).replaceWith(targetHtml);
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment