Establish base Snowboard framework in Backend (#548)

This PR establishes a base Snowboard framework in the Backend. While we won't likely have any specific Snowboard widgets or functionality in the 1.1 branch, it will allow people to use Snowboard in the Backend should they wish.

Fixes #541.

Co-authored-by: Luke Towers <github@luketowers.ca>
This commit is contained in:
Ben Thomson 2022-05-16 13:31:49 +08:00 committed by GitHub
parent ac130c462c
commit f79e672a13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1251 additions and 1082 deletions

View File

@ -58,3 +58,11 @@ jobs:
- name: Run code quality checks on System Module
working-directory: ./modules/system
run: npx eslint .
- name: Install Node dependencies for Backend Module
working-directory: ./modules/backend
run: npm install
- name: Run code quality checks on Backend Module
working-directory: ./modules/backend
run: npx eslint .

View File

@ -0,0 +1,14 @@
# Ignore build files
**/node_modules/**
build/*.js
**/build/*.js
**/mix.webpack.js
# Ignore all JS except for Mix-based assets
assets/js
assets/vendor
behaviors/**/*.js
controllers/**/*.js
formwidgets/**/*.js
reportwidgets/**/*.js
widgets/**/*.js

View File

@ -0,0 +1,36 @@
{
"env": {
"es6": true,
"browser": true
},
"globals": {
"Snowboard": "writable"
},
"extends": [
"airbnb-base",
"plugin:vue/vue3-recommended"
],
"rules": {
"class-methods-use-this": ["off"],
"indent": ["error", 4, {
"SwitchCase": 1
}],
"max-len": ["off"],
"new-cap": ["error", { "properties": false }],
"no-alert": ["off"],
"no-param-reassign": ["error", {
"props": false
}],
"vue/html-indent": ["error", 4],
"vue/html-self-closing": ["error", {
"html": {
"void": "never",
"normal": "any",
"component": "always"
},
"svg": "always",
"math": "always"
}],
"vue/multi-word-component-names": ["off"]
}
}

6
modules/backend/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# Backend module ignores
# Ignore Mix files
node_modules
package-lock.json
mix.webpack.js

View File

@ -2,19 +2,6 @@
* Winter General Utilities
*/
/*
* Ensure the CSRF token is added to all AJAX requests.
*/
$.ajaxPrefilter(function(options) {
var token = $('meta[name="csrf-token"]').attr('content')
if (token) {
if (!options.headers) options.headers = {}
options.headers['X-CSRF-TOKEN'] = token
}
})
/*
* Path helpers
*/
@ -36,133 +23,6 @@ $.wn.backendUrl = function(url) {
return backendBasePath + '/' + url
}
/*
* Asset Manager
*
* Usage: assetManager.load({ css:[], js:[], img:[] }, onLoadedCallback)
*/
AssetManager = function() {
var o = {
load: function(collection, callback) {
var jsList = (collection.js) ? collection.js : [],
cssList = (collection.css) ? collection.css : [],
imgList = (collection.img) ? collection.img : []
jsList = $.grep(jsList, function(item){
return $('head script[src="'+item+'"]').length == 0
})
cssList = $.grep(cssList, function(item){
return $('head link[href="'+item+'"]').length == 0
})
var cssCounter = 0,
jsLoaded = false,
imgLoaded = false
if (jsList.length === 0 && cssList.length === 0 && imgList.length === 0) {
callback && callback()
return
}
o.loadJavaScript(jsList, function(){
jsLoaded = true
checkLoaded()
})
$.each(cssList, function(index, source){
o.loadStyleSheet(source, function(){
cssCounter++
checkLoaded()
})
})
o.loadImage(imgList, function(){
imgLoaded = true
checkLoaded()
})
function checkLoaded() {
if (!imgLoaded)
return false
if (!jsLoaded)
return false
if (cssCounter < cssList.length)
return false
callback && callback()
}
},
/*
* Loads StyleSheet files
*/
loadStyleSheet: function(source, callback) {
var cssElement = document.createElement('link')
cssElement.setAttribute('rel', 'stylesheet')
cssElement.setAttribute('type', 'text/css')
cssElement.setAttribute('href', source)
cssElement.addEventListener('load', callback, false)
if (typeof cssElement != 'undefined') {
document.getElementsByTagName('head')[0].appendChild(cssElement)
}
return cssElement
},
/*
* Loads JavaScript files in sequence
*/
loadJavaScript: function(sources, callback) {
if (sources.length <= 0)
return callback()
var source = sources.shift(),
jsElement = document.createElement('script');
jsElement.setAttribute('type', 'text/javascript')
jsElement.setAttribute('src', source)
jsElement.addEventListener('load', function() {
o.loadJavaScript(sources, callback)
}, false)
if (typeof jsElement != 'undefined') {
document.getElementsByTagName('head')[0].appendChild(jsElement)
}
},
/*
* Loads Image files
*/
loadImage: function(sources, callback) {
if (sources.length <= 0)
return callback()
var loaded = 0
$.each(sources, function(index, source){
var img = new Image()
img.onload = function() {
if (++loaded == sources.length && callback)
callback()
}
img.src = source
})
}
};
return o;
};
assetManager = new AssetManager();
/*
* String escape
*/

View File

@ -1271,10 +1271,7 @@ return result?result:items}
$.fn.dateTimeConverter.Constructor=DateTimeConverter
$.fn.dateTimeConverter.noConflict=function(){$.fn.dateTimeConverter=old
return this}
$(document).render(function(){$('time[data-datetime-control]').dateTimeConverter()})}(window.jQuery);$.ajaxPrefilter(function(options){var token=$('meta[name="csrf-token"]').attr('content')
if(token){if(!options.headers)options.headers={}
options.headers['X-CSRF-TOKEN']=token}})
if($.wn===undefined)
$(document).render(function(){$('time[data-datetime-control]').dateTimeConverter()})}(window.jQuery);if($.wn===undefined)
$.wn={}
if($.oc===undefined)
$.oc=$.wn
@ -1284,42 +1281,7 @@ return url
if(url.substr(0,1)=='/')
url=url.substr(1)
return backendBasePath+'/'+url}
AssetManager=function(){var o={load:function(collection,callback){var jsList=(collection.js)?collection.js:[],cssList=(collection.css)?collection.css:[],imgList=(collection.img)?collection.img:[]
jsList=$.grep(jsList,function(item){return $('head script[src="'+item+'"]').length==0})
cssList=$.grep(cssList,function(item){return $('head link[href="'+item+'"]').length==0})
var cssCounter=0,jsLoaded=false,imgLoaded=false
if(jsList.length===0&&cssList.length===0&&imgList.length===0){callback&&callback()
return}
o.loadJavaScript(jsList,function(){jsLoaded=true
checkLoaded()})
$.each(cssList,function(index,source){o.loadStyleSheet(source,function(){cssCounter++
checkLoaded()})})
o.loadImage(imgList,function(){imgLoaded=true
checkLoaded()})
function checkLoaded(){if(!imgLoaded)
return false
if(!jsLoaded)
return false
if(cssCounter<cssList.length)
return false
callback&&callback()}},loadStyleSheet:function(source,callback){var cssElement=document.createElement('link')
cssElement.setAttribute('rel','stylesheet')
cssElement.setAttribute('type','text/css')
cssElement.setAttribute('href',source)
cssElement.addEventListener('load',callback,false)
if(typeof cssElement!='undefined'){document.getElementsByTagName('head')[0].appendChild(cssElement)}
return cssElement},loadJavaScript:function(sources,callback){if(sources.length<=0)
return callback()
var source=sources.shift(),jsElement=document.createElement('script');jsElement.setAttribute('type','text/javascript')
jsElement.setAttribute('src',source)
jsElement.addEventListener('load',function(){o.loadJavaScript(sources,callback)},false)
if(typeof jsElement!='undefined'){document.getElementsByTagName('head')[0].appendChild(jsElement)}},loadImage:function(sources,callback){if(sources.length<=0)
return callback()
var loaded=0
$.each(sources,function(index,source){var img=new Image()
img.onload=function(){if(++loaded==sources.length&&callback)
callback()}
img.src=source})}};return o;};assetManager=new AssetManager();if($.wn===undefined)
if($.wn===undefined)
$.wn={}
if($.oc===undefined)
$.oc=$.wn

View File

@ -0,0 +1,107 @@
/**
* Backend AJAX handler.
*
* This is a utility script that resolves some backwards-compatibility issues with the functionality
* that relies on the old framework, and ensures that Snowboard works well within the Backend
* environment.
*
* Functions:
* - Adds the "render" jQuery event to Snowboard requests that widgets use to initialise.
* - Ensures the CSRF token is included in requests.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Handler extends Snowboard.Singleton {
/**
* Event listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxFetchOptions: 'ajaxFetchOptions',
ajaxUpdateComplete: 'ajaxUpdateComplete',
};
}
/**
* Ready handler.
*
* Adds the jQuery AJAX prefilter that the old framework uses to inject the CSRF token in AJAX
* calls, and fires off a "render" event.
*/
ready() {
if (!window.jQuery) {
return;
}
window.jQuery.ajaxPrefilter((options) => {
if (this.hasToken()) {
if (!options.headers) {
options.headers = {};
}
options.headers['X-CSRF-TOKEN'] = this.getToken();
}
});
// Add "render" event for backwards compatibility
window.jQuery(document).trigger('render');
}
/**
* Fetch options handler.
*
* Ensures that the CSRF token is included in Snowboard requests.
*
* @param {Object} options
*/
ajaxFetchOptions(options) {
if (this.hasToken()) {
options.headers['X-CSRF-TOKEN'] = this.getToken();
}
}
/**
* Update complete handler.
*
* Fires off a "render" event when partials are updated so that any widgets included in
* responses are correctly initialised.
*/
ajaxUpdateComplete() {
if (!window.jQuery) {
return;
}
// Add "render" event for backwards compatibility
window.jQuery(document).trigger('render');
}
/**
* Determines if a CSRF token is available.
*
* @returns {Boolean}
*/
hasToken() {
const tokenElement = document.querySelector('meta[name="csrf-token"]');
if (!tokenElement) {
return false;
}
if (!tokenElement.hasAttribute('content')) {
return false;
}
return true;
}
/**
* Gets the CSRF token.
*
* @returns {String}
*/
getToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
import BackendAjaxHandler from './ajax/Handler';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the Backend UI.');
}
((Snowboard) => {
Snowboard.addPlugin('backend.ajax.handler', BackendAjaxHandler);
// Add polyfill for AssetManager
window.AssetManager = {
load: (assets, callback) => {
Snowboard.assetLoader().load(assets).finally(
() => {
if (callback && typeof callback === 'function') {
callback();
}
},
);
},
};
window.assetManager = window.AssetManager;
})(window.Snowboard);

View File

@ -1,6 +1,5 @@
<?= Form::open(['class'=>'layout-relative dashboard-container']) ?>
<div id="dashReportContainer" class="report-container loading">
<!-- Loading -->
<div class="loading-indicator-container">
<div class="loading-indicator indicator-center">
@ -8,14 +7,17 @@
<div><?= e(trans('backend::lang.list.loading')) ?></div>
</div>
</div>
</div>
<?= Form::close() ?>
<?php Block::put('head'); ?>
<script>
$(document).ready(function() {
$.request('onInitReportContainer').done(function() {
$('#dashReportContainer').removeClass('loading')
})
})
Snowboard.ready(() => {
Snowboard.request(null, 'onInitReportContainer', {
success: () => {
$('#dashReportContainer').removeClass('loading');
},
});
});
</script>
<?php Block::endPut(); ?>

View File

@ -26,9 +26,9 @@
Backend::skinAsset('assets/js/vendor/jquery-migrate.min.js'),
Url::asset('modules/system/assets/js/framework.js'),
Url::asset('modules/system/assets/js/build/manifest.js'),
Url::asset('modules/system/assets/js/build/vendor.js'),
Url::asset('modules/system/assets/js/snowboard/build/snowboard.vendor.js'),
Url::asset('modules/system/assets/js/build/system.js'),
Url::asset('modules/backend/assets/ui/js/build/backend.js'),
];
if (Config::get('develop.decompileBackendAssets', false)) {
$scripts = array_merge($scripts, Backend::decompileAsset('modules/system/assets/ui/storm.js'));

View File

@ -0,0 +1,35 @@
{
"name": "@wintercms/wn-backend-module",
"version": "1.0.0",
"description": "The Backend module for Winter CMS.",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/wintercms/winter.git"
},
"author": {
"name": "Ben Thomson",
"email": "git@alfreido.com",
"url": "https://wintercms.com"
},
"contributors": [
{
"name": "Winter CMS Maintainers",
"url": "https://wintercms.com"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/wintercms/winter/issues"
},
"homepage": "https://wintercms.com/",
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "^8.6.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-vue": "^8.5.0",
"laravel-mix": "^6.0.34",
"laravel-mix-polyfill": "^3.0.1"
}
}

View File

@ -0,0 +1,27 @@
/* eslint-disable */
const mix = require('laravel-mix');
require('laravel-mix-polyfill');
/* eslint-enable */
mix.setPublicPath(__dirname);
mix
.options({
terser: {
extractComments: false,
},
runtimeChunkPath: './assets/js/build',
})
// Compile Snowboard assets for the Backend
.js(
'./assets/ui/js/index.js',
'./assets/ui/js/build/backend.js',
)
// Polyfill for all targeted browsers
.polyfill({
enabled: mix.inProduction(),
useBuiltIns: 'usage',
targets: '> 0.5%, last 2 versions, not dead, Firefox ESR, not ie > 0',
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -66,6 +66,7 @@ class Request extends Snowboard.PluginBase {
(response) => {
if (response.cancelled) {
this.cancelled = true;
this.complete();
return;
}
this.responseData = response;
@ -83,24 +84,7 @@ class Request extends Snowboard.PluginBase {
this.responseError = error;
this.processError(error);
},
).finally(() => {
if (this.cancelled === true) {
return;
}
if (this.options.complete && typeof this.options.complete === 'function') {
this.options.complete(this.responseData, this);
}
this.snowboard.globalEvent('ajaxDone', this.responseData, this);
if (this.element) {
const event = new Event('ajaxAlways');
event.request = this;
event.responseData = this.responseData;
event.responseError = this.responseError;
this.element.dispatchEvent(event);
}
});
);
}
});
} else {
@ -108,6 +92,7 @@ class Request extends Snowboard.PluginBase {
(response) => {
if (response.cancelled) {
this.cancelled = true;
this.complete();
return;
}
this.responseData = response;
@ -125,24 +110,7 @@ class Request extends Snowboard.PluginBase {
this.responseError = error;
this.processError(error);
},
).finally(() => {
if (this.cancelled === true) {
return;
}
if (this.options.complete && typeof this.options.complete === 'function') {
this.options.complete(this.responseData, this);
}
this.snowboard.globalEvent('ajaxDone', this.responseData, this);
if (this.element) {
const event = new Event('ajaxAlways');
event.request = this;
event.responseData = this.responseData;
event.responseError = this.responseError;
this.element.dispatchEvent(event);
}
});
);
}
}
@ -329,13 +297,28 @@ class Request extends Snowboard.PluginBase {
});
if (Object.keys(partials).length === 0) {
resolve();
if (response.X_WINTER_ASSETS) {
this.processAssets(response.X_WINTER_ASSETS).then(
() => {
resolve();
},
() => {
reject();
},
);
} else {
resolve();
}
return;
}
const promises = this.snowboard.globalPromiseEvent('ajaxBeforeUpdate', response, this);
promises.then(
() => {
async () => {
if (response.X_WINTER_ASSETS) {
await this.processAssets(response.X_WINTER_ASSETS);
}
this.doUpdate(partials).then(
() => {
// Allow for HTML redraw
@ -456,9 +439,7 @@ class Request extends Snowboard.PluginBase {
return;
}
if (response.X_WINTER_ASSETS) {
this.processAssets(response.X_WINTER_ASSETS);
}
this.complete();
}
/**
@ -505,6 +486,8 @@ class Request extends Snowboard.PluginBase {
this.processErrorMessage(error.X_WINTER_ERROR_MESSAGE);
}
}
this.complete();
}
/**
@ -625,6 +608,21 @@ class Request extends Snowboard.PluginBase {
this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this);
}
/**
* Processes assets returned by an AJAX request.
*
* By default, no asset processing will occur and this will return a resolved Promise.
*
* Plugins can augment this functionality from the `ajaxLoadAssets` event. This event is considered blocking, and
* allows assets to be loaded or processed before continuing with any additional functionality.
*
* @param {Object} assets
* @returns {Promise}
*/
processAssets(assets) {
return this.snowboard.globalPromiseEvent('ajaxLoadAssets', assets);
}
/**
* Confirms the request with the user before proceeding.
*
@ -668,6 +666,24 @@ class Request extends Snowboard.PluginBase {
return false;
}
/**
* Fires off completion events for the Request.
*/
complete() {
if (this.options.complete && typeof this.options.complete === 'function') {
this.options.complete(this.responseData, this);
}
this.snowboard.globalEvent('ajaxDone', this.responseData, this);
if (this.element) {
const event = new Event('ajaxAlways');
event.request = this;
event.responseData = this.responseData;
event.responseError = this.responseError;
this.element.dispatchEvent(event);
}
}
get form() {
if (this.options.form) {
return this.options.form;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
(self.webpackChunk_wintercms_wn_system_module=self.webpackChunk_wintercms_wn_system_module||[]).push([[806],{3738:function(){function e(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);t&&(a=a.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,a)}return r}function t(t){for(var a=1;a<arguments.length;a++){var n=null!=arguments[a]?arguments[a]:{};a%2?e(Object(n),!0).forEach((function(e){r(t,e,n[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):e(Object(n)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(n,e))}))}return t}function r(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}if(void 0===window.Snowboard)throw new Error("Snowboard must be loaded in order to use the Data Attributes plugin.");class a extends Snowboard.Singleton{listens(){return{ready:"ready",ajaxSetup:"onAjaxSetup"}}ready(){this.attachHandlers(),this.disableDefaultFormValidation()}dependencies(){return["request","jsonParser"]}destructor(){this.detachHandlers(),super.destructor()}attachHandlers(){window.addEventListener("change",(e=>this.changeHandler(e))),window.addEventListener("click",(e=>this.clickHandler(e))),window.addEventListener("keydown",(e=>this.keyDownHandler(e))),window.addEventListener("submit",(e=>this.submitHandler(e)))}disableDefaultFormValidation(){document.querySelectorAll("form[data-request]:not([data-browser-validate])").forEach((e=>{e.setAttribute("novalidate",!0)}))}detachHandlers(){window.removeEventListener("change",(e=>this.changeHandler(e))),window.removeEventListener("click",(e=>this.clickHandler(e))),window.removeEventListener("keydown",(e=>this.keyDownHandler(e))),window.removeEventListener("submit",(e=>this.submitHandler(e)))}changeHandler(e){e.target.matches("select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]")&&this.processRequestOnElement(e.target)}clickHandler(e){let t=e.target;for(;"HTML"!==t.tagName;){if(t.matches("a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]")){e.preventDefault(),this.processRequestOnElement(t);break}t=t.parentElement}}keyDownHandler(e){if(!e.target.matches("input"))return;-1!==["checkbox","color","date","datetime","datetime-local","email","image","month","number","password","radio","range","search","tel","text","time","url","week"].indexOf(e.target.getAttribute("type"))&&("Enter"===e.key&&e.target.matches("*[data-request]")?(this.processRequestOnElement(e.target),e.preventDefault(),e.stopImmediatePropagation()):e.target.matches("*[data-track-input]")&&this.trackInput(e.target))}submitHandler(e){e.target.matches("form[data-request]")&&(e.preventDefault(),this.processRequestOnElement(e.target))}processRequestOnElement(e){const t=e.dataset,r=String(t.request),a={confirm:"requestConfirm"in t?String(t.requestConfirm):null,redirect:"requestRedirect"in t?String(t.requestRedirect):null,loading:"requestLoading"in t?String(t.requestLoading):null,flash:"requestFlash"in t,files:"requestFiles"in t,browserValidate:"requestBrowserValidate"in t,form:"requestForm"in t?String(t.requestForm):null,url:"requestUrl"in t?String(t.requestUrl):null,update:"requestUpdate"in t?this.parseData(String(t.requestUpdate)):[],data:"requestData"in t?this.parseData(String(t.requestData)):[]};this.snowboard.request(e,r,a)}onAjaxSetup(e){if(!e.element)return;const r=e.element.getAttribute("name"),a=t(t({},this.getParentRequestData(e.element)),e.options.data);e.element&&e.element.matches("input, textarea, select, button")&&!e.form&&r&&!e.options.data[r]&&(a[r]=e.element.value),e.options.data=a}getParentRequestData(e){const r=[];let a={},n=e;for(;n.parentElement&&"HTML"!==n.parentElement.tagName;)r.push(n.parentElement),n=n.parentElement;return r.reverse(),r.forEach((e=>{const r=e.dataset;"requestData"in r&&(a=t(t({},a),this.parseData(r.requestData)))})),a}parseData(e){let t;if(void 0===e&&(t=""),"object"==typeof t)return t;try{return this.snowboard.jsonparser().parse("{".concat(e,"}"))}catch(e){throw new Error("Error parsing the data attribute on element: ".concat(e.message))}}trackInput(e){const{lastValue:t}=e.dataset,r=e.dataset.trackInput||300;void 0!==t&&t===e.value||(this.resetTrackInputTimer(e),e.dataset.trackInput=window.setTimeout((()=>{if(e.dataset.request)return void this.processRequestOnElement(e);let t=e;for(;t.parentElement&&"HTML"!==t.parentElement.tagName;)if(t=t.parentElement,"FORM"===t.tagName&&t.dataset.request){this.processRequestOnElement(t);break}}),r))}resetTrackInputTimer(e){e.dataset.trackInput&&(window.clearTimeout(e.dataset.trackInput),e.dataset.trackInput=null)}}Snowboard.addPlugin("attributeRequest",a)}},function(e){var t;t=3738,e(e.s=t)}]);
(self.webpackChunk_wintercms_wn_system_module=self.webpackChunk_wintercms_wn_system_module||[]).push([[806],{9250:function(){function e(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(e);t&&(a=a.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,a)}return r}function t(t){for(var a=1;a<arguments.length;a++){var n=null!=arguments[a]?arguments[a]:{};a%2?e(Object(n),!0).forEach((function(e){r(t,e,n[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(n)):e(Object(n)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(n,e))}))}return t}function r(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}if(void 0===window.Snowboard)throw new Error("Snowboard must be loaded in order to use the Data Attributes plugin.");class a extends Snowboard.Singleton{listens(){return{ready:"ready",ajaxSetup:"onAjaxSetup"}}ready(){this.attachHandlers(),this.disableDefaultFormValidation()}dependencies(){return["request","jsonParser"]}destructor(){this.detachHandlers(),super.destructor()}attachHandlers(){window.addEventListener("change",(e=>this.changeHandler(e))),window.addEventListener("click",(e=>this.clickHandler(e))),window.addEventListener("keydown",(e=>this.keyDownHandler(e))),window.addEventListener("submit",(e=>this.submitHandler(e)))}disableDefaultFormValidation(){document.querySelectorAll("form[data-request]:not([data-browser-validate])").forEach((e=>{e.setAttribute("novalidate",!0)}))}detachHandlers(){window.removeEventListener("change",(e=>this.changeHandler(e))),window.removeEventListener("click",(e=>this.clickHandler(e))),window.removeEventListener("keydown",(e=>this.keyDownHandler(e))),window.removeEventListener("submit",(e=>this.submitHandler(e)))}changeHandler(e){e.target.matches("select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]")&&this.processRequestOnElement(e.target)}clickHandler(e){let t=e.target;for(;"HTML"!==t.tagName;){if(t.matches("a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]")){e.preventDefault(),this.processRequestOnElement(t);break}t=t.parentElement}}keyDownHandler(e){if(!e.target.matches("input"))return;-1!==["checkbox","color","date","datetime","datetime-local","email","image","month","number","password","radio","range","search","tel","text","time","url","week"].indexOf(e.target.getAttribute("type"))&&("Enter"===e.key&&e.target.matches("*[data-request]")?(this.processRequestOnElement(e.target),e.preventDefault(),e.stopImmediatePropagation()):e.target.matches("*[data-track-input]")&&this.trackInput(e.target))}submitHandler(e){e.target.matches("form[data-request]")&&(e.preventDefault(),this.processRequestOnElement(e.target))}processRequestOnElement(e){const t=e.dataset,r=String(t.request),a={confirm:"requestConfirm"in t?String(t.requestConfirm):null,redirect:"requestRedirect"in t?String(t.requestRedirect):null,loading:"requestLoading"in t?String(t.requestLoading):null,flash:"requestFlash"in t,files:"requestFiles"in t,browserValidate:"requestBrowserValidate"in t,form:"requestForm"in t?String(t.requestForm):null,url:"requestUrl"in t?String(t.requestUrl):null,update:"requestUpdate"in t?this.parseData(String(t.requestUpdate)):[],data:"requestData"in t?this.parseData(String(t.requestData)):[]};this.snowboard.request(e,r,a)}onAjaxSetup(e){if(!e.element)return;const r=e.element.getAttribute("name"),a=t(t({},this.getParentRequestData(e.element)),e.options.data);e.element&&e.element.matches("input, textarea, select, button")&&!e.form&&r&&!e.options.data[r]&&(a[r]=e.element.value),e.options.data=a}getParentRequestData(e){const r=[];let a={},n=e;for(;n.parentElement&&"HTML"!==n.parentElement.tagName;)r.push(n.parentElement),n=n.parentElement;return r.reverse(),r.forEach((e=>{const r=e.dataset;"requestData"in r&&(a=t(t({},a),this.parseData(r.requestData)))})),a}parseData(e){let t;if(void 0===e&&(t=""),"object"==typeof t)return t;try{return this.snowboard.jsonparser().parse("{".concat(e,"}"))}catch(e){throw new Error("Error parsing the data attribute on element: ".concat(e.message))}}trackInput(e){const{lastValue:t}=e.dataset,r=e.dataset.trackInput||300;void 0!==t&&t===e.value||(this.resetTrackInputTimer(e),e.dataset.trackInput=window.setTimeout((()=>{if(e.dataset.request)return void this.processRequestOnElement(e);let t=e;for(;t.parentElement&&"HTML"!==t.parentElement.tagName;)if(t=t.parentElement,"FORM"===t.tagName&&t.dataset.request){this.processRequestOnElement(t);break}}),r))}resetTrackInputTimer(e){e.dataset.trackInput&&(window.clearTimeout(e.dataset.trackInput),e.dataset.trackInput=null)}}Snowboard.addPlugin("attributeRequest",a)}},function(e){var t;t=9250,e(e.s=t)}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,173 @@
/**
* Asset Loader.
*
* Provides simple asset loading functionality for Snowboard, making it easy to pre-load images or
* include JavaScript or CSS assets on the fly.
*
* By default, this loader will listen to any assets that have been requested to load in an AJAX
* response, such as responses from a component.
*
* You can also load assets manually by calling the following:
*
* ```js
* Snowboard.addPlugin('assetLoader', AssetLoader);
* Snowboard.assetLoader().processAssets(assets);
* ```
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AssetLoader extends window.Snowboard.Singleton {
/**
* Event listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxLoadAssets: 'load',
};
}
/**
* Process and load assets.
*
* The `assets` property of this method requires an object with any of the following keys and an
* array of paths:
*
* - `js`: An array of JavaScript URLs to load
* - `css`: An array of CSS stylesheet URLs to load
* - `img`: An array of image URLs to pre-load
*
* Both `js` and `css` files will be automatically injected, however `img` files will not.
*
* This method will return a Promise that resolves when all required assets are loaded. If an
* asset fails to load, this Promise will be rejected.
*
* @param {Object} assets
* @returns {Promise}
*/
load(assets) {
return new Promise((resolve, reject) => {
const promises = [];
if (assets.js && assets.js.length > 0) {
assets.js.forEach((script) => {
promises.push(this.loadScript(script));
});
}
if (assets.css && assets.css.length > 0) {
assets.css.forEach((style) => {
promises.push(this.loadStyle(style));
});
}
if (assets.img && assets.img.length > 0) {
assets.img.forEach((image) => {
promises.push(this.loadImage(image));
});
}
if (promises.length === 0) {
resolve();
}
Promise.all(promises).then(
() => {
resolve();
},
(error) => {
reject(error);
},
);
});
}
/**
* Injects and loads a JavaScript URL into the DOM.
*
* The script will be appended before the closing `</body>` tag.
*
* @param {String} script
* @returns {Promise}
*/
loadScript(script) {
return new Promise((resolve, reject) => {
// Check that script is not already loaded
const loaded = document.querySelector(`script[src="${script}"]`);
if (loaded) {
resolve();
}
// Create script
const domScript = document.createElement('script');
domScript.setAttribute('type', 'text/javascript');
domScript.setAttribute('src', script);
domScript.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'script', script, domScript);
resolve();
});
domScript.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'script', script, domScript);
reject(new Error(`Unable to load script file: "${script}"`));
});
document.body.append(domScript);
});
}
/**
* Injects and loads a CSS stylesheet into the DOM.
*
* The stylesheet will be appended before the closing `</head>` tag.
*
* @param {String} script
* @returns {Promise}
*/
loadStyle(style) {
return new Promise((resolve, reject) => {
// Check that stylesheet is not already loaded
const loaded = document.querySelector(`link[rel="stylesheet"][href="${style}"]`);
if (loaded) {
resolve();
}
// Create stylesheet
const domCss = document.createElement('link');
domCss.setAttribute('rel', 'stylesheet');
domCss.setAttribute('href', style);
domCss.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'style', style, domCss);
resolve();
});
domCss.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'style', style, domCss);
reject(new Error(`Unable to load stylesheet file: "${style}"`));
});
document.head.append(domCss);
});
}
/**
* Pre-loads an image.
*
* The image will not be injected into the DOM.
*
* @param {String} image
* @returns {Promise}
*/
loadImage(image) {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener('load', () => {
this.snowboard.globalEvent('assetLoader.loaded', 'image', image, img);
resolve();
});
img.addEventListener('error', () => {
this.snowboard.globalEvent('assetLoader.error', 'image', image, img);
reject(new Error(`Unable to load image file: "${image}"`));
});
img.src = image;
});
}
}

View File

@ -0,0 +1,212 @@
/**
* Data configuration provider.
*
* Provides a mechanism for passing configuration data through an element's data attributes. This
* is generally used for widgets or UI interactions to configure them.
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class DataConfig extends Snowboard.PluginBase {
/**
* Constructor.
*
* @param {Snowboard} snowboard
* @param {Snowboard.PluginBase} instance
* @param {HTMLElement} element
*/
constructor(snowboard, instance, element) {
super(snowboard);
if (instance instanceof Snowboard.PluginBase === false) {
throw new Error('You must provide a Snowboard plugin to enable data configuration');
}
if (element instanceof HTMLElement === false) {
throw new Error('Data configuration can only be extracted from HTML elements');
}
this.instance = instance;
this.element = element;
this.refresh();
}
/**
* Gets the config for this instance.
*
* If the `config` parameter is unspecified, returns the entire configuration.
*
* @param {string} config
*/
get(config) {
if (config === undefined) {
return this.instanceConfig;
}
if (this.instanceConfig[config] !== undefined) {
return this.instanceConfig[config];
}
return undefined;
}
/**
* Sets the config for this instance.
*
* This allows you to override, at runtime, any configuration value as necessary.
*
* @param {string} config
* @param {any} value
* @param {boolean} persist
*/
set(config, value, persist) {
if (config === undefined) {
throw new Error('You must provide a configuration key to set');
}
this.instanceConfig[config] = value;
if (persist === true) {
this.element.dataset[config] = value;
}
}
/**
* Refreshes the configuration from the element.
*
* This will allow you to make changes to the data config on a DOM level and re-apply them
* to the config on the JavaScript side.
*/
refresh() {
this.acceptedConfigs = this.getAcceptedConfigs();
this.instanceConfig = this.processConfig();
}
/**
* Determines the available configurations that can be set through the data config.
*
* If an instance has an `acceptAllDataConfigs` property, set to `true`, then all data
* attributes will be available as configuration values. This can be a security concern, so
* tread carefully.
*
* Otherwise, available configurations will be determined by the keys available in an object
* returned by a `defaults()` method in the instance.
*
* @returns {string[]|boolean}
*/
getAcceptedConfigs() {
if (
this.instance.acceptAllDataConfigs !== undefined
&& this.instance.acceptAllDataConfigs === true
) {
return true;
}
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return Object.keys(this.instance.defaults());
}
return false;
}
/**
* Returns the default values for the instance.
*
* This will be an empty object if the instance either does not have a `defaults()` method, or
* the method itself does not return an object.
*
* @returns {object}
*/
getDefaults() {
if (
this.instance.defaults !== undefined
&& typeof this.instance.defaults === 'function'
&& typeof this.instance.defaults() === 'object'
) {
return this.instance.defaults();
}
return {};
}
/**
* Processes the configuration.
*
* Loads up the defaults, then populates it with any configuration values provided by the data
* attributes, based on the rules of the accepted configurations.
*
* This configuration object is then cached and available through `config.get()` calls.
*
* @returns {object}
*/
processConfig() {
const config = this.getDefaults();
if (this.acceptedConfigs === false) {
return config;
}
/* eslint-disable */
for (const key in this.element.dataset) {
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
config[key] = this.coerceValue(this.element.dataset[key]);
}
}
/* eslint-enable */
return config;
}
/**
* Coerces configuration values for JavaScript.
*
* Takes the string value returned from the data attribute and coerces it into a more suitable
* type for JavaScript processing.
*
* @param {*} value
* @returns {*}
*/
coerceValue(value) {
const stringValue = String(value);
// Null value
if (stringValue === 'null') {
return null;
}
// Undefined value
if (stringValue === 'undefined') {
return undefined;
}
// Base64 value
if (stringValue.startsWith('base64:')) {
const base64str = stringValue.replace(/^base64:/, '');
const decoded = atob(base64str);
return this.coerceValue(decoded);
}
// Boolean value
if (['true', 'yes'].includes(stringValue.toLowerCase())) {
return true;
}
if (['false', 'no'].includes(stringValue.toLowerCase())) {
return false;
}
// Numeric value
if (/^[-+]?[0-9]+(\.[0-9]+)?$/.test(stringValue)) {
return Number(stringValue);
}
// JSON value
try {
return this.snowboard.jsonParser().parse(stringValue);
} catch (e) {
return (stringValue === '') ? true : stringValue;
}
}
}

View File

@ -29,7 +29,9 @@ export default class Snowboard {
this.debugEnabled = (typeof debug === 'boolean' && debug === true);
this.autoInitSingletons = (typeof autoSingletons === 'boolean' && autoSingletons === false);
this.plugins = {};
this.listeners = {};
this.foundBaseUrl = null;
this.domReady = false;
this.attachAbstracts();
this.loadUtilities();
@ -77,6 +79,7 @@ export default class Snowboard {
this.initialiseSingletons();
}
this.globalEvent('ready');
this.domReady = true;
});
}
@ -127,7 +130,8 @@ export default class Snowboard {
this.debug(`Plugin "${name}" registered`);
// Check if any singletons now have their dependencies fulfilled, and fire their "ready" handler.
// Check if any singletons now have their dependencies fulfilled, and fire their "ready" handler if we're
// in a ready state.
Object.values(this.getPlugins()).forEach((plugin) => {
if (
plugin.isSingleton()
@ -135,6 +139,7 @@ export default class Snowboard {
&& plugin.dependenciesFulfilled()
&& plugin.hasMethod('listens')
&& Object.keys(plugin.callMethod('listens')).includes('ready')
&& this.domReady
) {
const readyMethod = plugin.callMethod('listens').ready;
plugin.callMethod(readyMethod);
@ -249,6 +254,61 @@ export default class Snowboard {
return plugins;
}
/**
* Add a simple ready listener.
*
* Synonymous with jQuery's "$(document).ready()" functionality, this allows inline scripts to
* attach themselves to Snowboard immediately but only fire when the DOM is ready.
*
* @param {Function} callback
*/
ready(callback) {
if (this.domReady) {
callback();
}
this.on('ready', callback);
}
/**
* Adds a simple listener for an event.
*
* This can be used for ad-hoc scripts that don't need a full plugin. The given callback will be
* called when the event name provided fires. This works for both normal and Promise events. For
* a Promise event, your callback must return a Promise.
*
* @param {String} eventName
* @param {Function} callback
*/
on(eventName, callback) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
if (!this.listeners[eventName].includes(callback)) {
this.listeners[eventName].push(callback);
}
}
/**
* Removes a simple listener for an event.
*
* @param {String} eventName
* @param {Function} callback
*/
off(eventName, callback) {
if (!this.listeners[eventName]) {
return;
}
const index = this.listeners[eventName].indexOf(callback);
if (index === -1) {
return;
}
this.listeners[eventName].splice(index, 1);
}
/**
* Calls a global event to all registered plugins.
*
@ -260,7 +320,7 @@ export default class Snowboard {
globalEvent(eventName, ...parameters) {
this.debug(`Calling global event "${eventName}"`);
// Find out which plugins listen to this event - if none listen to it, return true.
// Find plugins listening to the event.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global event "${eventName}"`);
@ -300,6 +360,23 @@ export default class Snowboard {
});
});
// Find ad-hoc listeners for this event.
if (!cancelled && this.listeners[eventName] && this.listeners[eventName].length > 0) {
this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global event "${eventName}"`);
this.listeners[eventName].forEach((listener) => {
// If a listener has cancelled the event, no further listeners are considered.
if (cancelled) {
return;
}
if (listener(...parameters) === false) {
cancelled = true;
this.debug(`Global event "${eventName} cancelled by an ad-hoc listener.`);
}
});
}
return !cancelled;
}
@ -314,7 +391,7 @@ export default class Snowboard {
globalPromiseEvent(eventName, ...parameters) {
this.debug(`Calling global promise event "${eventName}"`);
// Find out which plugins listen to this event - if none listen to it, return a resolved promise.
// Find plugins listening to this event.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global promise event "${eventName}"`);
@ -347,6 +424,20 @@ export default class Snowboard {
});
});
// Find ad-hoc listeners listening to this event.
if (this.listeners[eventName] && this.listeners[eventName].length > 0) {
this.debug(`Found ${this.listeners[eventName].length} ad-hoc listener(s) for global promise event "${eventName}"`);
this.listeners[eventName].forEach((listener) => {
const listenerPromise = listener(...parameters);
if (listenerPromise instanceof Promise === false) {
return;
}
promises.push(listenerPromise);
});
}
if (promises.length === 0) {
return Promise.resolve();
}

View File

@ -3,12 +3,16 @@ import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
import StylesheetLoader from './extras/StylesheetLoader';
import AssetLoader from './extras/AssetLoader';
import DataConfig from './extras/DataConfig';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the extra plugins.');
}
((Snowboard) => {
Snowboard.addPlugin('assetLoader', AssetLoader);
Snowboard.addPlugin('dataConfig', DataConfig);
Snowboard.addPlugin('extrasStyles', StylesheetLoader);
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);

View File

@ -4,12 +4,16 @@ import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
import StylesheetLoader from './extras/StylesheetLoader';
import AssetLoader from './extras/AssetLoader';
import DataConfig from './extras/DataConfig';
if (window.Snowboard === undefined) {
throw new Error('Snowboard must be loaded in order to use the extra plugins.');
}
((Snowboard) => {
Snowboard.addPlugin('assetLoader', AssetLoader);
Snowboard.addPlugin('dataConfig', DataConfig);
Snowboard.addPlugin('extrasStyles', StylesheetLoader);
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);

View File

@ -24,9 +24,7 @@
},
"homepage": "https://wintercms.com/",
"dependencies": {
"@popperjs/core": "^2.11.4",
"js-cookie": "^3.0.1",
"vue": "^3.2.31"
"js-cookie": "^3.0.1"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
@ -35,8 +33,6 @@
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-vue": "^8.5.0",
"laravel-mix": "^6.0.34",
"laravel-mix-polyfill": "^3.0.1",
"less-loader": "^10.2.0",
"vue-loader": "^16.8.3"
"laravel-mix-polyfill": "^3.0.1"
}
}

View File

@ -12,20 +12,12 @@ mix
},
runtimeChunkPath: './assets/js/build',
})
.vue({ version: 3 })
// Extract imported libraries
.extract({
libraries: ['js-cookie'],
to: './assets/js/snowboard/build/snowboard.vendor.js',
})
.extract({
libraries: [
'@popperjs/core',
'vue',
],
to: './assets/js/build/vendor.js',
})
// Compile Snowboard for the Backend / System
.js(

View File

@ -4,6 +4,7 @@
},
"workspaces": {
"packages": [
"modules/backend",
"modules/system"
]
}

View File

@ -8,7 +8,8 @@
"module-resolver", {
"root": ["."],
"alias": {
"helpers": "./helpers"
"helpers": "./helpers",
"snowboard": "../../modules/system/assets/js/snowboard",
}
}
]

View File

@ -0,0 +1,316 @@
import FakeDom from '../../../helpers/FakeDom';
jest.setTimeout(2000);
describe('The Data Config extra functionality', function () {
it('can read the config from an element\'s data attributes', function (done) {
FakeDom
.new()
.addCss([
'modules/system/assets/css/snowboard.extras.css',
])
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.extras.js',
'tests/js/fixtures/dataConfig/DataConfigFixture.js',
])
.render(
`<div
id="testElement"
data-id="389"
data-string-value="Hi there"
data-boolean="true"
></div>
<div
id="testElementTwo"
data-string-value="Hi there again"
data-name="Ben"
data-boolean="false"
data-extra-attr="This should not be available"
data-base64="base64:SSdtIGEgQmFzZTY0LWRlY29kZWQgc3RyaW5n"
></div>`
)
.then(
(dom) => {
const instance = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElement')
);
try {
expect(instance.config.get('id')).toEqual(389);
// Name should be null as it's the default value and not specified above
expect(instance.config.get('name')).toBeNull();
expect(instance.config.get('stringValue')).toBe('Hi there');
// Missing should be undefined as it's neither defined nor part of the default data
expect(instance.config.get('missing')).toBeUndefined();
expect(instance.config.get('boolean')).toBe(true);
expect(instance.config.get()).toMatchObject({
id: 389,
name: null,
stringValue: 'Hi there',
boolean: true,
base64: null,
});
} catch (error) {
done(error);
return;
}
const instanceTwo = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElementTwo')
);
try {
// ID is null as it's the default value and not specified above
expect(instanceTwo.config.get('id')).toBeNull();
expect(instanceTwo.config.get('name')).toBe('Ben');
expect(instanceTwo.config.get('stringValue')).toBe('Hi there again');
expect(instanceTwo.config.get('missing')).toBeUndefined();
expect(instanceTwo.config.get('boolean')).toBe(false);
// Extra attr is specified above, but it should not be available as a config value
// because it's not part of the `defaults()` in the fixture
expect(instanceTwo.config.get('extraAttr')).toBeUndefined();
// Base-64 decoded string
expect(instanceTwo.config.get('base64')).toBe('I\'m a Base64-decoded string');
done();
} catch (error) {
done(error);
}
}
);
});
it('can read the config from every data attribute of an element with "acceptAllDataConfigs" enabled', function (done) {
FakeDom
.new()
.addCss([
'modules/system/assets/css/snowboard.extras.css',
])
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.extras.js',
'tests/js/fixtures/dataConfig/DataConfigFixture.js',
])
.render(
`<div
id="testElementTwo"
data-string-value="Hi there again"
data-name="Ben"
data-boolean="false"
data-extra-attr="This should now be available"
data-json="{ &quot;name&quot;: &quot;Ben&quot; }"
data-another-base64="base64:dHJ1ZQ=="
data-json-base64="base64:eyAiaWQiOiAxLCAidGl0bGUiOiAiU29tZSB0aXRsZSIgfQ=="
></div>`
)
.then(
(dom) => {
const instance = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElementTwo')
);
instance.acceptAllDataConfigs = true;
instance.config.refresh();
try {
// ID is null as it's the default value and not specified above
expect(instance.config.get('id')).toBeNull();
expect(instance.config.get('name')).toBe('Ben');
expect(instance.config.get('stringValue')).toBe('Hi there again');
expect(instance.config.get('missing')).toBeUndefined();
expect(instance.config.get('boolean')).toBe(false);
// These attributes below are specified above, and although they're not part of the
// defaults, they should be available because "acceptAllDataConfigs" is true
expect(instance.config.get('extraAttr')).toBe('This should now be available');
expect(instance.config.get('json')).toMatchObject({
name: 'Ben'
});
expect(instance.config.get('anotherBase64')).toBe(true);
expect(instance.config.get('jsonBase64')).toMatchObject({
id: 1,
title: 'Some title',
});
expect(instance.config.get()).toMatchObject({
id: null,
name: 'Ben',
stringValue: 'Hi there again',
boolean: false,
extraAttr: 'This should now be available',
json: {
name: 'Ben',
},
anotherBase64: true,
jsonBase64: {
id: 1,
title: 'Some title',
},
});
done();
} catch (error) {
done(error);
}
}
);
});
it('can refresh the config from the data attributes on the fly', function (done) {
FakeDom
.new()
.addCss([
'modules/system/assets/css/snowboard.extras.css',
])
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.extras.js',
'tests/js/fixtures/dataConfig/DataConfigFixture.js',
])
.render(
`<div
id="testElement"
data-string-value="Hi there again"
data-name="Ben"
data-boolean="no"
></div>`
)
.then(
(dom) => {
const instance = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElement')
);
try {
expect(instance.config.get('id')).toBeNull();
expect(instance.config.get('name')).toBe('Ben');
expect(instance.config.get('stringValue')).toBe('Hi there again');
expect(instance.config.get('boolean')).toBe(false);
expect(instance.config.get()).toMatchObject({
id: null,
name: 'Ben',
stringValue: 'Hi there again',
boolean: false,
});
dom.window.document.querySelector('#testElement').setAttribute('data-id', '456');
dom.window.document.querySelector('#testElement').setAttribute('data-string-value', 'Changed');
dom.window.document.querySelector('#testElement').removeAttribute('data-boolean');
// Refresh config
instance.config.refresh();
expect(instance.config.get('id')).toBe(456);
expect(instance.config.get('name')).toBe('Ben');
expect(instance.config.get('stringValue')).toBe('Changed');
expect(instance.config.get('boolean')).toBeNull();
expect(instance.config.get()).toMatchObject({
id: 456,
name: 'Ben',
stringValue: 'Changed',
boolean: null,
});
done();
} catch (error) {
done(error);
}
}
);
});
it('can set config values at runtime', function (done) {
FakeDom
.new()
.addCss([
'modules/system/assets/css/snowboard.extras.css',
])
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.extras.js',
'tests/js/fixtures/dataConfig/DataConfigFixture.js',
])
.render(
`<div
id="testElement"
data-string-value="Hi there again"
data-name="Ben"
data-boolean="false"
></div>`
)
.then(
(dom) => {
const instance = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElement')
);
try {
expect(instance.config.get('name')).toBe('Ben');
// Set config
instance.config.set('name', 'Luke');
expect(instance.config.get('name')).toBe('Luke');
// Refresh config
instance.config.refresh();
expect(instance.config.get('name')).toBe('Ben');
done();
} catch (error) {
done(error);
}
}
);
});
it('can set config values at runtime that persist through a reset', function (done) {
FakeDom
.new()
.addCss([
'modules/system/assets/css/snowboard.extras.css',
])
.addScript([
'modules/system/assets/js/build/manifest.js',
'modules/system/assets/js/snowboard/build/snowboard.vendor.js',
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.extras.js',
'tests/js/fixtures/dataConfig/DataConfigFixture.js',
])
.render(
`<div
id="testElement"
data-string-value="Hi there again"
data-name="Ben"
data-boolean="no"
></div>`
)
.then(
(dom) => {
const instance = dom.window.Snowboard.dataConfigFixture(
dom.window.document.querySelector('#testElement')
);
try {
expect(instance.config.get('name')).toBe('Ben');
// Set config
instance.config.set('name', 'Luke', true);
expect(instance.config.get('name')).toBe('Luke');
// Refresh config
instance.config.refresh();
expect(instance.config.get('name')).toBe('Luke');
done();
} catch (error) {
done(error);
}
}
);
});
});

View File

@ -0,0 +1,28 @@
/* globals window */
((Snowboard) => {
class DataConfigFixture extends Snowboard.PluginBase {
constructor(snowboard, element) {
super(snowboard);
this.element = element;
this.config = this.snowboard.dataConfig(this, element);
}
dependencies() {
return ['dataConfig'];
}
defaults() {
return {
id: null,
name: null,
stringValue: null,
boolean: null,
base64: null,
};
}
}
Snowboard.addPlugin('dataConfigFixture', DataConfigFixture);
})(window.Snowboard);

View File

@ -8,68 +8,98 @@ export default class FakeDom
constructor(content, options)
{
if (options === undefined) {
options = {}
options = {};
}
// Header settings
this.url = options.url || `file://${path.resolve(__dirname, '../../')}`
this.referer = options.referer
this.contentType = options.contentType || 'text/html'
this.url = options.url || `file://${path.resolve(__dirname, '../../')}`;
this.referer = options.referer;
this.contentType = options.contentType || 'text/html';
// Content settings
this.head = options.head || '<!DOCTYPE html><html><head><title>Fake document</title></head>'
this.bodyStart = options.bodyStart || '<body>'
this.content = content || ''
this.bodyEnd = options.bodyEnd || '</body>'
this.foot = options.foot || '</html>'
this.headStart = options.headStart || '<!DOCTYPE html><html><head><title>Fake document</title>';
this.headEnd = options.headEnd || '</head>';
this.bodyStart = options.bodyStart || '<body>';
this.content = content || '';
this.bodyEnd = options.bodyEnd || '</body>';
this.foot = options.foot || '</html>';
// Callback settings
this.beforeParse = (typeof options.beforeParse === 'function')
? options.beforeParse
: undefined
: undefined;
// Scripts
this.scripts = []
this.inline = []
// Assets
this.css = [];
this.scripts = [];
this.inline = [];
}
static new(content, options)
{
return new FakeDom(content, options)
return new FakeDom(content, options);
}
setContent(content)
{
this.content = content
return this
this.content = content;
return this;
}
addScript(script, id)
{
if (Array.isArray(script)) {
script.forEach((item) => {
this.addScript(item)
})
this.addScript(item);
});
return this
return this;
}
let url = new URL(script, this.url)
let base = new URL(this.url)
let url = new URL(script, this.url);
let base = new URL(this.url);
if (url.host === base.host) {
this.scripts.push({
url: `${url.pathname}`,
id: id || this.generateId(),
})
});
} else {
this.scripts.push({
url,
id: id || this.generateId(),
})
});
}
return this
return this;
}
addCss(css, id)
{
if (Array.isArray(css)) {
css.forEach((item) => {
this.addCss(item)
});
return this;
}
let url = new URL(css, this.url);
let base = new URL(this.url);
if (url.host === base.host) {
this.css.push({
url: `${url.pathname}`,
id: id || this.generateId(),
});
} else {
this.css.push({
url,
id: id || this.generateId(),
});
}
return this;
}
addInlineScript(script, id)
@ -78,23 +108,23 @@ export default class FakeDom
script,
id: id || this.generateId(),
element: null,
})
});
return this
return this;
}
generateId()
{
let id = 'script-'
let chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'
let charLength = chars.length
let id = 'script-';
let chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
let charLength = chars.length;
for (let i = 0; i < 10; i++) {
let currentChar = chars.substr(Math.floor(Math.random() * charLength), 1)
id = `${id}${currentChar}`
let currentChar = chars.substr(Math.floor(Math.random() * charLength), 1);
id = `${id}${currentChar}`;
}
return id
return id;
}
render(content)
@ -116,41 +146,44 @@ export default class FakeDom
pretendToBeVisual: true,
beforeParse: this.beforeParse,
}
)
);
dom.window.resolver = () => {
resolve(dom)
}
resolve(dom);
};
} catch (e) {
reject(e)
reject(e);
}
})
});
}
_renderContent()
{
// Create content list
const content = [
this.head,
this.bodyStart,
this.content,
]
const content = [this.headStart];
// Embed CSS
this.css.forEach((css) => {
content.push(`<link rel="stylesheet" href="${css.url}" id="${css.id}">`);
});
content.push(this.headEnd, this.bodyStart, this.content);
// Embed scripts
this.scripts.forEach((script) => {
content.push(`<script src="${script.url}" id="${script.id}"></script>`)
})
content.push(`<script src="${script.url}" id="${script.id}"></script>`);
});
this.inline.forEach((script) => {
content.push(`<script id="${script.id}">${script.script}</script>`)
})
content.push(`<script id="${script.id}">${script.script}</script>`);
});
// Add resolver
content.push(`<script>window.resolver()</script>`)
content.push(`<script>window.resolver()</script>`);
// Add final content
content.push(this.bodyEnd)
content.push(this.foot)
content.push(this.bodyEnd);
content.push(this.foot);
return content.join('\n')
return content.join('\n');
}
}