Last active
September 30, 2019 13:42
-
-
Save FabianSchmick/8220f77a1087f31f72a92f4f1afb49d1 to your computer and use it in GitHub Desktop.
Javascript SPA
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% 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 %} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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">×</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> | |
`; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% 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 %} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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