Compile changes from wip/framework-rewrite branch

Represents the entire current state of the Snowboard framework.
This commit is contained in:
Ben Thomson 2022-01-12 15:35:45 +08:00
parent 6bbfd0e885
commit ae67e2fa9b
60 changed files with 5386 additions and 918 deletions

View File

@ -1,13 +0,0 @@
{
"presets": ["@babel/preset-env"],
"plugins": [
[
"module-resolver", {
"root": ["."],
"alias": {
"helpers": "./tests/js/helpers"
}
}
]
]
}

View File

@ -37,3 +37,24 @@ jobs:
- name: Run code quality checks (on pull request)
if: github.event_name == 'pull_request'
run: ./.github/workflows/utilities/phpcs-pr ${{ github.base_ref }}
codeQualityJS:
runs-on: ubuntu-latest
name: JavaScript
steps:
- name: Checkout changes
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 12
- name: Install Node dependencies
working-directory: ./modules/system/assets/js/snowboard
run: npm install
- name: Run code quality checks
working-directory: ./modules/system/assets/js/snowboard
run: npx eslint .

View File

@ -24,9 +24,11 @@ jobs:
node-version: 12
- name: Install Node dependencies
working-directory: ./tests/js
run: npm install
- name: Run tests
working-directory: ./tests/js
run: npm run test
phpUnitTests:
strategy:

View File

@ -1,5 +0,0 @@
{
"esversion": 6,
"curly": true,
"asi": true
}

View File

@ -0,0 +1,65 @@
<?php namespace Cms\Twig;
use Config;
use Request;
use System\Models\Parameter;
use System\Classes\CombineAssets;
use Twig\Node\Node as TwigNode;
use Twig\Compiler as TwigCompiler;
/**
* Represents a "snowboard" node
*
* @package winter\wn-cms-module
* @author Winter CMS
*/
class SnowboardNode extends TwigNode
{
/**
* @var bool Indicates if the base Snowboard framework is already loaded, in case of multiple uses of this tag.
*/
public static $baseLoaded = false;
public function __construct(array $modules, $lineno, $tag = 'snowboard')
{
parent::__construct([], ['modules' => $modules], $lineno, $tag);
}
/**
* Compiles the node to PHP.
*
* @param TwigCompiler $compiler A TwigCompiler instance
*/
public function compile(TwigCompiler $compiler)
{
$build = Parameter::get('system::core.build', 'winter');
$cacheBust = '?v=' . $build;
$modules = $this->getAttribute('modules');
$compiler
->addDebugInfo($this)
->write("\$_minify = ".CombineAssets::class."::instance()->useMinify;" . PHP_EOL);
$moduleMap = [
'base' => (Config::get('app.debug', false) === true) ? 'snowboard.base.debug' : 'snowboard.base',
'request' => 'snowboard.request',
'attr' => 'snowboard.data-attr',
'extras' => 'snowboard.extras',
];
$basePath = Request::getBasePath() . '/modules/system/assets/js/snowboard/build/';
if (!static::$baseLoaded) {
// Add base script
$baseJs = $moduleMap['base'];
$compiler
->write("echo '<script src=\"${basePath}${baseJs}.js${cacheBust}\"></script>'.PHP_EOL;" . PHP_EOL);
static::$baseLoaded = true;
}
foreach ($modules as $module) {
$moduleJs = $moduleMap[$module];
$compiler
->write("echo '<script src=\"${basePath}${moduleJs}.js${cacheBust}\"></script>'.PHP_EOL;" . PHP_EOL);
}
}
}

View File

@ -0,0 +1,60 @@
<?php namespace Cms\Twig;
use Twig\Token as TwigToken;
use Twig\TokenParser\AbstractTokenParser as TwigTokenParser;
/**
* Parser for the `{% snowboard %}` Twig tag.
*
* @package winter\wn-cms-module
* @author Winter CMS
*/
class SnowboardTokenParser extends TwigTokenParser
{
/**
* @inheritDoc
*/
public function parse(TwigToken $token)
{
$lineno = $token->getLine();
$stream = $this->parser->getStream();
$modules = [];
do {
$token = $stream->next();
if ($token->getType() === TwigToken::NAME_TYPE) {
$modules[] = $token->getValue();
}
} while ($token->getType() !== TwigToken::BLOCK_END_TYPE);
// Filter out invalid types
$modules = array_filter(
array_map(function ($item) {
return strtolower($item);
}, $modules),
function ($item) {
return in_array($item, ['request', 'attr', 'extras', 'all']);
}
);
if (in_array('all', $modules)) {
$modules = [
'request',
'attr',
'extras',
];
}
return new SnowboardNode($modules, $lineno, $this->getTag());
}
/**
* @inheritDoc
*/
public function getTag()
{
return 'snowboard';
}
}

View File

@ -32,6 +32,7 @@ use Winter\Storm\Support\ModuleServiceProvider;
use Winter\Storm\Router\Helper as RouterHelper;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema;
use System\Classes\MixAssets;
class ServiceProvider extends ModuleServiceProvider
{
@ -252,32 +253,37 @@ class ServiceProvider extends ModuleServiceProvider
/*
* Register console commands
*/
$this->registerConsoleCommand('winter.up', 'System\Console\WinterUp');
$this->registerConsoleCommand('winter.down', 'System\Console\WinterDown');
$this->registerConsoleCommand('winter.update', 'System\Console\WinterUpdate');
$this->registerConsoleCommand('winter.util', 'System\Console\WinterUtil');
$this->registerConsoleCommand('winter.mirror', 'System\Console\WinterMirror');
$this->registerConsoleCommand('winter.fresh', 'System\Console\WinterFresh');
$this->registerConsoleCommand('winter.env', 'System\Console\WinterEnv');
$this->registerConsoleCommand('winter.install', 'System\Console\WinterInstall');
$this->registerConsoleCommand('winter.passwd', 'System\Console\WinterPasswd');
$this->registerConsoleCommand('winter.version', 'System\Console\WinterVersion');
$this->registerConsoleCommand('winter.manifest', 'System\Console\WinterManifest');
$this->registerConsoleCommand('winter.test', 'System\Console\WinterTest');
$this->registerConsoleCommand('winter.up', \System\Console\WinterUp::class);
$this->registerConsoleCommand('winter.down', \System\Console\WinterDown::class);
$this->registerConsoleCommand('winter.update', \System\Console\WinterUpdate::class);
$this->registerConsoleCommand('winter.util', \System\Console\WinterUtil::class);
$this->registerConsoleCommand('winter.mirror', \System\Console\WinterMirror::class);
$this->registerConsoleCommand('winter.fresh', \System\Console\WinterFresh::class);
$this->registerConsoleCommand('winter.env', \System\Console\WinterEnv::class);
$this->registerConsoleCommand('winter.install', \System\Console\WinterInstall::class);
$this->registerConsoleCommand('winter.passwd', \System\Console\WinterPasswd::class);
$this->registerConsoleCommand('winter.version', \System\Console\WinterVersion::class);
$this->registerConsoleCommand('winter.manifest', \System\Console\WinterManifest::class);
$this->registerConsoleCommand('winter.test', \System\Console\WinterTest::class);
$this->registerConsoleCommand('plugin.install', 'System\Console\PluginInstall');
$this->registerConsoleCommand('plugin.remove', 'System\Console\PluginRemove');
$this->registerConsoleCommand('plugin.disable', 'System\Console\PluginDisable');
$this->registerConsoleCommand('plugin.enable', 'System\Console\PluginEnable');
$this->registerConsoleCommand('plugin.refresh', 'System\Console\PluginRefresh');
$this->registerConsoleCommand('plugin.rollback', 'System\Console\PluginRollback');
$this->registerConsoleCommand('plugin.list', 'System\Console\PluginList');
$this->registerConsoleCommand('plugin.install', \System\Console\PluginInstall::class);
$this->registerConsoleCommand('plugin.remove', \System\Console\PluginRemove::class);
$this->registerConsoleCommand('plugin.disable', \System\Console\PluginDisable::class);
$this->registerConsoleCommand('plugin.enable', \System\Console\PluginEnable::class);
$this->registerConsoleCommand('plugin.refresh', \System\Console\PluginRefresh::class);
$this->registerConsoleCommand('plugin.rollback', \System\Console\PluginRollback::class);
$this->registerConsoleCommand('plugin.list', \System\Console\PluginList::class);
$this->registerConsoleCommand('theme.install', 'System\Console\ThemeInstall');
$this->registerConsoleCommand('theme.remove', 'System\Console\ThemeRemove');
$this->registerConsoleCommand('theme.list', 'System\Console\ThemeList');
$this->registerConsoleCommand('theme.use', 'System\Console\ThemeUse');
$this->registerConsoleCommand('theme.sync', 'System\Console\ThemeSync');
$this->registerConsoleCommand('theme.install', \System\Console\ThemeInstall::class);
$this->registerConsoleCommand('theme.remove', \System\Console\ThemeRemove::class);
$this->registerConsoleCommand('theme.list', \System\Console\ThemeList::class);
$this->registerConsoleCommand('theme.use', \System\Console\ThemeUse::class);
$this->registerConsoleCommand('theme.sync', \System\Console\ThemeSync::class);
$this->registerConsoleCommand('mix.install', \System\Console\MixInstall::class);
$this->registerConsoleCommand('mix.list', \System\Console\MixList::class);
$this->registerConsoleCommand('mix.compile', \System\Console\MixCompile::class);
$this->registerConsoleCommand('mix.watch', \System\Console\MixWatch::class);
}
/*
@ -561,6 +567,11 @@ class ServiceProvider extends ModuleServiceProvider
$combiner->registerBundle('~/modules/system/assets/js/framework.js');
$combiner->registerBundle('~/modules/system/assets/js/framework.combined.js');
$combiner->registerBundle('~/modules/system/assets/less/framework.extras.less');
$combiner->registerBundle('~/modules/system/assets/less/snowboard.extras.less');
});
MixAssets::registerCallback(function ($mix) {
$mix->registerPackage('snowboard', '~/modules/system/assets/js/snowboard');
});
}

View File

@ -0,0 +1,2 @@
**/node_modules/**
build/*.js

View File

@ -0,0 +1,22 @@
{
"env": {
"es6": true,
"browser": true
},
"globals": {
"Snowboard": "writable"
},
"extends": "airbnb-base",
"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
}]
}
}

View File

@ -0,0 +1,3 @@
# Ignore packages
node_modules
package-lock.json

View File

@ -0,0 +1,48 @@
/**
* Plugin base abstract.
*
* This class provides the base functionality for all plugins.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class PluginBase {
/**
* Constructor.
*
* The constructor is provided the Snowboard framework instance.
*
* @param {Snowboard} snowboard
*/
constructor(snowboard) {
this.snowboard = snowboard;
}
/**
* Defines the required plugins for this specific module to work.
*
* @returns {string[]} An array of plugins required for this module to work, as strings.
*/
dependencies() {
return [];
}
/**
* Defines the listener methods for global events.
*
* @returns {Object}
*/
listens() {
return {};
}
/**
* Destructor.
*
* Fired when this plugin is removed.
*/
destructor() {
this.detach();
delete this.snowboard;
}
}

View File

@ -0,0 +1,15 @@
import PluginBase from './PluginBase';
/**
* Singleton plugin abstract.
*
* This is a special definition class that the Snowboard framework will use to interpret the current plugin as a
* "singleton". This will ensure that only one instance of the plugin class is used across the board.
*
* Singletons are initialised on the "domReady" event by default.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Singleton extends PluginBase {
}

View File

@ -0,0 +1,789 @@
/**
* Request plugin.
*
* This is the default AJAX handler which will run using the `fetch()` method that is default in modern browsers.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
class Request extends Snowboard.PluginBase {
/**
* Constructor.
*
* @param {Snowboard} snowboard
* @param {HTMLElement|string} element
* @param {string} handler
* @param {Object} options
*/
constructor(snowboard, element, handler, options) {
super(snowboard);
if (typeof element === 'string') {
const matchedElement = document.querySelector(element);
if (matchedElement === null) {
throw new Error(`No element was found with the given selector: ${element}`);
}
this.element = matchedElement;
} else {
this.element = element;
}
this.handler = handler;
this.options = options || {};
this.responseData = null;
this.responseError = null;
this.cancelled = false;
this.checkRequest();
if (!this.snowboard.globalEvent('ajaxSetup', this)) {
this.cancelled = true;
return;
}
if (this.element) {
const event = new Event('ajaxSetup', { cancelable: true });
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
this.cancelled = true;
return;
}
}
if (!this.doClientValidation()) {
this.cancelled = true;
return;
}
if (this.confirm) {
this.doConfirm().then((confirmed) => {
if (confirmed) {
this.doAjax().then(
(response) => {
if (response.cancelled) {
this.cancelled = true;
return;
}
this.responseData = response;
this.processUpdate(response).then(
() => {
if (response.X_WINTER_SUCCESS === false) {
this.processError(response);
} else {
this.processResponse(response);
}
},
);
},
(error) => {
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 {
this.doAjax().then(
(response) => {
if (response.cancelled) {
this.cancelled = true;
return;
}
this.responseData = response;
this.processUpdate(response).then(
() => {
if (response.X_WINTER_SUCCESS === false) {
this.processError(response);
} else {
this.processResponse(response);
}
},
);
},
(error) => {
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);
}
});
}
}
/**
* Dependencies for this plugin.
*
* @returns {string[]}
*/
dependencies() {
return ['jsonParser', 'sanitizer'];
}
/**
* Validates the element and handler given in the request.
*/
checkRequest() {
if (this.element !== undefined && this.element instanceof Element === false) {
throw new Error('The element provided must be an Element instance');
}
if (this.handler === undefined) {
throw new Error('The AJAX handler name is not specified.');
}
if (!this.handler.match(/^(?:\w+:{2})?on*/)) {
throw new Error('Invalid AJAX handler name. The correct handler name format is: "onEvent".');
}
}
/**
* Run client-side validation on the form, if available.
*
* @returns {boolean}
*/
doClientValidation() {
if (this.options.browserValidate === true && this.form) {
if (this.form.checkValidity() === false) {
this.form.reportValidity();
return false;
}
}
return true;
}
/**
* Executes the AJAX query.
*
* Returns a Promise object for when the AJAX request is completed.
*
* @returns {Promise}
*/
doAjax() {
// Allow plugins to cancel the AJAX request before sending
if (this.snowboard.globalEvent('ajaxBeforeSend', this) === false) {
return Promise.resolve({
cancelled: true,
});
}
const ajaxPromise = new Promise((resolve, reject) => {
fetch(this.url, {
method: 'POST',
headers: this.headers,
body: this.data,
redirect: 'follow',
mode: 'same-origin',
}).then(
(response) => {
if (!response.ok && response.status !== 406) {
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) {
response.json().then(
(responseData) => {
reject(this.renderError(
responseData.message,
responseData.exception,
responseData.file,
responseData.line,
responseData.trace,
));
},
(error) => {
reject(this.renderError(`Unable to parse JSON response: ${error}`));
},
);
} else {
response.text().then(
(responseText) => {
reject(this.renderError(responseText));
},
(error) => {
reject(this.renderError(`Unable to process response: ${error}`));
},
);
}
return;
}
if (response.headers.has('Content-Type') && response.headers.get('Content-Type').includes('/json')) {
response.json().then(
(responseData) => {
resolve({
...responseData,
X_WINTER_SUCCESS: response.status !== 406,
X_WINTER_RESPONSE_CODE: response.status,
});
},
(error) => {
reject(this.renderError(`Unable to parse JSON response: ${error}`));
},
);
} else {
response.text().then(
(responseData) => {
resolve(responseData);
},
(error) => {
reject(this.renderError(`Unable to process response: ${error}`));
},
);
}
},
(responseError) => {
reject(this.renderError(`Unable to retrieve a response from the server: ${responseError}`));
},
);
});
this.snowboard.globalEvent('ajaxStart', ajaxPromise, this);
if (this.element) {
const event = new Event('ajaxPromise');
event.promise = ajaxPromise;
this.element.dispatchEvent(event);
}
return ajaxPromise;
}
/**
* Prepares for updating the partials from the AJAX response.
*
* If any partials are returned from the AJAX response, this method will also action the partial updates.
*
* Returns a Promise object which tracks when the partial update is complete.
*
* @param {Object} response
* @returns {Promise}
*/
processUpdate(response) {
return new Promise((resolve, reject) => {
if (typeof this.options.beforeUpdate === 'function') {
if (this.options.beforeUpdate.apply(this, [response]) === false) {
reject();
return;
}
}
// Extract partial information
const partials = {};
Object.entries(response).forEach((entry) => {
const [key, value] = entry;
if (key.substr(0, 8) !== 'X_WINTER') {
partials[key] = value;
}
});
if (Object.keys(partials).length === 0) {
resolve();
return;
}
const promises = this.snowboard.globalPromiseEvent('ajaxBeforeUpdate', response, this);
promises.then(
() => {
this.doUpdate(partials).then(
() => {
// Allow for HTML redraw
window.requestAnimationFrame(() => resolve());
},
() => {
reject();
},
);
},
() => {
reject();
},
);
});
}
/**
* Updates the partials with the given content.
*
* @param {Object} partials
* @returns {Promise}
*/
doUpdate(partials) {
return new Promise((resolve) => {
const affected = [];
Object.entries(partials).forEach((entry) => {
const [partial, content] = entry;
let selector = (this.options.update && this.options.update[partial])
? this.options.update[partial]
: partial;
let mode = 'replace';
if (selector.substr(0, 1) === '@') {
mode = 'append';
selector = selector.substr(1);
} else if (selector.substr(0, 1) === '^') {
mode = 'prepend';
selector = selector.substr(1);
}
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach((element) => {
const sanitizedContent = this.snowboard.sanitizer().sanitize(content);
switch (mode) {
case 'append':
element.innerHTML += sanitizedContent;
break;
case 'prepend':
element.innerHTML = sanitizedContent + element.innerHTML;
break;
case 'replace':
default:
element.innerHTML = sanitizedContent;
break;
}
affected.push(element);
// Fire update event for each element that is updated
this.snowboard.globalEvent('ajaxUpdate', element, sanitizedContent, this);
const event = new Event('ajaxUpdate');
event.content = sanitizedContent;
element.dispatchEvent(event);
});
}
});
this.snowboard.globalEvent('ajaxUpdateComplete', affected, this);
resolve();
});
}
/**
* Processes the response data.
*
* This fires off all necessary processing functions depending on the response, ie. if there's any flash
* messages to handle, or any redirects to be undertaken.
*
* @param {Object} response
* @returns {void}
*/
processResponse(response) {
if (this.options.success && typeof this.options.success === 'function') {
if (!this.options.success(this.responseData, this)) {
return;
}
}
// Allow plugins to cancel any further response handling
if (this.snowboard.globalEvent('ajaxSuccess', this.responseData, this) === false) {
return;
}
// Allow the element to cancel any further response handling
if (this.element) {
const event = new Event('ajaxDone', { cancelable: true });
event.responseData = this.responseData;
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
return;
}
}
// Check for a redirect from the response, or use the redirect as specified in the options. This takes
// precedent over all other checks.
if (this.redirect || response.X_WINTER_REDIRECT) {
this.processRedirect(this.redirect || response.X_WINTER_REDIRECT);
return;
}
if (this.flash && response.X_WINTER_FLASH_MESSAGES) {
this.processFlashMessages(response.X_WINTER_FLASH_MESSAGES);
}
if (response.X_WINTER_ASSETS) {
this.processAssets(response.X_WINTER_ASSETS);
}
}
/**
* Processes an error response from the AJAX request.
*
* This fires off all necessary processing functions depending on the error response, ie. if there's any error or
* validation messages to handle.
*
* @param {Object|Error} error
*/
processError(error) {
if (this.options.error && typeof this.options.error === 'function') {
if (!this.options.error(this.responseError, this)) {
return;
}
}
// Allow plugins to cancel any further error handling
if (this.snowboard.globalEvent('ajaxError', this.responseError, this) === false) {
return;
}
// Allow the element to cancel any further error handling
if (this.element) {
const event = new Event('ajaxFail', { cancelable: true });
event.responseError = this.responseError;
event.request = this;
this.element.dispatchEvent(event);
if (event.defaultPrevented) {
return;
}
}
if (error instanceof Error) {
this.processErrorMessage(error.message);
} else {
// Process validation errors
if (error.X_WINTER_ERROR_FIELDS) {
this.processValidationErrors(error.X_WINTER_ERROR_FIELDS);
}
if (error.X_WINTER_ERROR_MESSAGE) {
this.processErrorMessage(error.X_WINTER_ERROR_MESSAGE);
}
}
}
/**
* Processes a redirect response.
*
* By default, this processor will simply redirect the user in their browser.
*
* Plugins can augment this functionality from the `ajaxRedirect` event. You may also override this functionality on
* a per-request basis through the `handleRedirectResponse` callback option. If a `false` is returned from either, the
* redirect will be cancelled.
*
* @param {string} url
* @returns {void}
*/
processRedirect(url) {
// Run a custom per-request redirect handler. If false is returned, don't run the redirect.
if (typeof this.options.handleRedirectResponse === 'function') {
if (this.options.handleRedirectResponse.apply(this, [url]) === false) {
return;
}
}
// Allow plugins to cancel the redirect
if (this.snowboard.globalEvent('ajaxRedirect', url, this) === false) {
return;
}
// Indicate that the AJAX request is finished if we're still on the current page
// so that the loading indicator for redirects that just change the hash value of
// the URL instead of leaving the page will properly stop.
// @see https://github.com/octobercms/october/issues/2780
window.addEventListener('popstate', () => {
if (this.element) {
const event = document.createEvent('CustomEvent');
event.eventName = 'ajaxRedirected';
this.element.dispatchEvent(event);
}
}, {
once: true,
});
window.location.assign(url);
}
/**
* Processes an error message.
*
* By default, this processor will simply alert the user through a simple `alert()` call.
*
* Plugins can augment this functionality from the `ajaxErrorMessage` event. You may also override this functionality
* on a per-request basis through the `handleErrorMessage` callback option. If a `false` is returned from either, the
* error message handling will be cancelled.
*
* @param {string} message
* @returns {void}
*/
processErrorMessage(message) {
// Run a custom per-request handler for error messages. If false is returned, do not process the error messages
// any further.
if (typeof this.options.handleErrorMessage === 'function') {
if (this.options.handleErrorMessage.apply(this, [message]) === false) {
return;
}
}
// Allow plugins to cancel the error message being shown
if (this.snowboard.globalEvent('ajaxErrorMessage', message, this) === false) {
return;
}
// By default, show a browser error message
window.alert(message);
}
/**
* Processes flash messages from the response.
*
* By default, no flash message handling will occur.
*
* Plugins can augment this functionality from the `ajaxFlashMessages` event. You may also override this functionality
* on a per-request basis through the `handleFlashMessages` callback option. If a `false` is returned from either, the
* flash message handling will be cancelled.
*
* @param {Object} messages
* @returns
*/
processFlashMessages(messages) {
// Run a custom per-request flash handler. If false is returned, don't show the flash message
if (typeof this.options.handleFlashMessages === 'function') {
if (this.options.handleFlashMessages.apply(this, [messages]) === false) {
return;
}
}
this.snowboard.globalEvent('ajaxFlashMessages', messages, this);
}
/**
* Processes validation errors for fields.
*
* By default, no validation error handling will occur.
*
* Plugins can augment this functionality from the `ajaxValidationErrors` event. You may also override this functionality
* on a per-request basis through the `handleValidationErrors` callback option. If a `false` is returned from either, the
* validation error handling will be cancelled.
*
* @param {Object} fields
* @returns
*/
processValidationErrors(fields) {
if (typeof this.options.handleValidationErrors === 'function') {
if (this.options.handleValidationErrors.apply(this, [this.form, fields]) === false) {
return;
}
}
// Allow plugins to cancel the validation errors being handled
this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this);
}
/**
* Confirms the request with the user before proceeding.
*
* This is an asynchronous method. By default, it will use the browser's `confirm()` method to query the user to
* confirm the action. This method will return a Promise with a boolean value depending on whether the user confirmed
* or not.
*
* Plugins can augment this functionality from the `ajaxConfirmMessage` event. You may also override this functionality
* on a per-request basis through the `handleConfirmMessage` callback option. If a `false` is returned from either,
* the confirmation is assumed to have been denied.
*
* @returns {Promise}
*/
async doConfirm() {
// Allow for a custom handler for the confirmation, per request.
if (typeof this.options.handleConfirmMessage === 'function') {
if (this.options.handleConfirmMessage.apply(this, [this.confirm]) === false) {
return false;
}
return true;
}
// If no plugins have customised the confirmation, use a simple browser confirmation.
if (this.snowboard.listensToEvent('ajaxConfirmMessage').length === 0) {
return window.confirm(this.confirm);
}
// Run custom plugin confirmations
const promises = this.snowboard.globalPromiseEvent('ajaxConfirmMessage', this.confirm, this);
try {
const fulfilled = await promises;
if (fulfilled) {
return true;
}
} catch (e) {
return false;
}
return false;
}
get form() {
if (this.options.form) {
return this.options.form;
}
if (!this.element) {
return null;
}
if (this.element.tagName === 'FORM') {
return this.element;
}
return this.element.closest('form');
}
get context() {
return {
handler: this.handler,
options: this.options,
};
}
get headers() {
const headers = {
'X-Requested-With': 'XMLHttpRequest', // Keeps compatibility with jQuery AJAX
'X-WINTER-REQUEST-HANDLER': this.handler,
'X-WINTER-REQUEST-PARTIALS': this.extractPartials(this.options.update || []),
};
if (this.flash) {
headers['X-WINTER-REQUEST-FLASH'] = 1;
}
if (this.xsrfToken) {
headers['X-XSRF-TOKEN'] = this.xsrfToken;
}
return headers;
}
get loading() {
return this.options.loading || false;
}
get url() {
return this.options.url || window.location.href;
}
get redirect() {
return (this.options.redirect && this.options.redirect.length) ? this.options.redirect : null;
}
get flash() {
return this.options.flash || false;
}
get files() {
if (this.options.files === true) {
if (FormData === undefined) {
this.snowboard.debug('This browser does not support file uploads');
return false;
}
return true;
}
return false;
}
get xsrfToken() {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i += 1) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 11) === 'XSRF-TOKEN=') {
cookieValue = decodeURIComponent(cookie.substring(11));
break;
}
}
}
return cookieValue;
}
get data() {
const data = (typeof this.options.data === 'object') ? this.options.data : {};
const formData = new FormData(this.form || undefined);
if (Object.keys(data).length > 0) {
Object.entries(data).forEach((entry) => {
const [key, value] = entry;
formData.append(key, value);
});
}
return formData;
}
get confirm() {
return this.options.confirm || false;
}
/**
* Extracts partials.
*
* @param {Object} update
* @returns {string}
*/
extractPartials(update) {
return Object.keys(update).join('&');
}
/**
* Renders an error with useful debug information.
*
* This method is used internally when the AJAX request could not be completed or processed correctly due to an error.
*
* @param {string} message
* @param {string} exception
* @param {string} file
* @param {Number} line
* @param {string[]} trace
* @returns {Error}
*/
renderError(message, exception, file, line, trace) {
const error = new Error(message);
error.exception = exception || null;
error.file = file || null;
error.line = line || null;
error.trace = trace || [];
return error;
}
}
Snowboard.addPlugin('request', Request);

View File

@ -0,0 +1,327 @@
/**
* Enable Data Attributes API for AJAX requests.
*
* This is an extension of the base AJAX functionality that includes handling of HTML data attributes for processing
* AJAX requests. It is separated from the base AJAX functionality to allow developers to opt-out of data attribute
* requests if they do not intend to use them.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
class AttributeRequest extends Snowboard.Singleton {
/**
* Listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxSetup: 'onAjaxSetup',
};
}
/**
* Ready event callback.
*
* Attaches handlers to the window to listen for all request interactions.
*/
ready() {
this.attachHandlers();
this.disableDefaultFormValidation();
}
/**
* Dependencies.
*
* @returns {string[]}
*/
dependencies() {
return ['request', 'jsonParser'];
}
/**
* Destructor.
*
* Detaches all handlers.
*/
destructor() {
this.detachHandlers();
super.destructor();
}
/**
* Attaches the necessary handlers for all request interactions.
*/
attachHandlers() {
window.addEventListener('change', (event) => this.changeHandler(event));
window.addEventListener('click', (event) => this.clickHandler(event));
window.addEventListener('keydown', (event) => this.keyDownHandler(event));
window.addEventListener('submit', (event) => this.submitHandler(event));
}
/**
* Disables default form validation for AJAX forms.
*
* A form that contains a `data-request` attribute to specify an AJAX call without including a `data-browser-validate`
* attribute means that the AJAX callback function will likely be handling the validation instead.
*/
disableDefaultFormValidation() {
document.querySelectorAll('form[data-request]:not([data-browser-validate])').forEach((form) => {
form.setAttribute('novalidate', true);
});
}
/**
* Detaches the necessary handlers for all request interactions.
*/
detachHandlers() {
window.removeEventListener('change', (event) => this.changeHandler(event));
window.removeEventListener('click', (event) => this.clickHandler(event));
window.removeEventListener('keydown', (event) => this.keyDownHandler(event));
window.removeEventListener('submit', (event) => this.submitHandler(event));
}
/**
* Handles changes to select, radio, checkbox and file inputs.
*
* @param {Event} event
*/
changeHandler(event) {
// Check that we are changing a valid element
if (!event.target.matches(
'select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]',
)) {
return;
}
this.processRequestOnElement(event.target);
}
/**
* Handles clicks on hyperlinks and buttons.
*
* This event can bubble up the hierarchy to find a suitable request element.
*
* @param {Event} event
*/
clickHandler(event) {
let currentElement = event.target;
while (currentElement.tagName !== 'HTML') {
if (!currentElement.matches(
'a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]',
)) {
currentElement = currentElement.parentElement;
} else {
event.preventDefault();
this.processRequestOnElement(currentElement);
break;
}
}
}
/**
* Handles key presses on inputs
*
* @param {Event} event
*/
keyDownHandler(event) {
// Check that we are inputting into a valid element
if (!event.target.matches(
'input',
)) {
return;
}
// Check that the input type is valid
const validTypes = [
'checkbox',
'color',
'date',
'datetime',
'datetime-local',
'email',
'image',
'month',
'number',
'password',
'radio',
'range',
'search',
'tel',
'text',
'time',
'url',
'week',
];
if (validTypes.indexOf(event.target.getAttribute('type')) === -1) {
return;
}
if (event.key === 'Enter' && event.target.matches('*[data-request]')) {
this.processRequestOnElement(event.target);
event.preventDefault();
event.stopImmediatePropagation();
} else if (event.target.matches('*[data-track-input]')) {
this.trackInput(event.target);
}
}
/**
* Handles form submissions.
*
* @param {Event} event
*/
submitHandler(event) {
// Check that we are submitting a valid form
if (!event.target.matches(
'form[data-request]',
)) {
return;
}
event.preventDefault();
this.processRequestOnElement(event.target);
}
/**
* Processes a request on a given element, using its data attributes.
*
* @param {HTMLElement} element
*/
processRequestOnElement(element) {
const data = element.dataset;
const handler = String(data.request);
const options = {
confirm: ('requestConfirm' in data) ? String(data.requestConfirm) : null,
redirect: ('requestRedirect' in data) ? String(data.requestRedirect) : null,
loading: ('requestLoading' in data) ? String(data.requestLoading) : null,
flash: ('requestFlash' in data),
files: ('requestFiles' in data),
browserValidate: ('requestBrowserValidate' in data),
form: ('requestForm' in data) ? String(data.requestForm) : null,
url: ('requestUrl' in data) ? String(data.requestUrl) : null,
update: ('requestUpdate' in data) ? this.parseData(String(data.requestUpdate)) : [],
data: ('requestData' in data) ? this.parseData(String(data.requestData)) : [],
};
this.snowboard.request(element, handler, options);
}
/**
* Sets up an AJAX request via HTML attributes.
*
* @param {Request} request
*/
onAjaxSetup(request) {
const fieldName = request.element.getAttribute('name');
const data = {
...this.getParentRequestData(request.element),
...request.options.data,
};
if (request.element && request.element.matches('input, textarea, select, button') && !request.form && fieldName && !request.options.data[fieldName]) {
data[fieldName] = request.element.value;
}
request.options.data = data;
}
/**
* Parses and collates all data from elements up the DOM hierarchy.
*
* @param {Element} target
* @returns {Object}
*/
getParentRequestData(target) {
const elements = [];
let data = {};
let currentElement = target;
while (currentElement.parentElement && currentElement.parentElement.tagName !== 'HTML') {
elements.push(currentElement.parentElement);
currentElement = currentElement.parentElement;
}
elements.reverse();
elements.forEach((element) => {
const elementData = element.dataset;
if ('requestData' in elementData) {
data = {
...data,
...this.parseData(elementData.requestData),
};
}
});
return data;
}
/**
* Parses data in the Winter/October JSON format.
*
* @param {String} data
* @returns {Object}
*/
parseData(data) {
let value;
if (data === undefined) {
value = '';
}
if (typeof value === 'object') {
return value;
}
try {
return this.snowboard.jsonparser().parse(`{${data}}`);
} catch (e) {
throw new Error(`Error parsing the data attribute on element: ${e.message}`);
}
}
trackInput(element) {
const { lastValue } = element.dataset;
const interval = element.dataset.trackInput || 300;
if (lastValue !== undefined && lastValue === element.value) {
return;
}
this.resetTrackInputTimer(element);
element.dataset.trackInput = window.setTimeout(() => {
if (element.dataset.request) {
this.processRequestOnElement(element);
return;
}
// Traverse up the hierarchy and find a form that sends an AJAX query
let currentElement = element;
while (currentElement.parentElement && currentElement.parentElement.tagName !== 'HTML') {
currentElement = currentElement.parentElement;
if (currentElement.tagName === 'FORM' && currentElement.dataset.request) {
this.processRequestOnElement(currentElement);
break;
}
}
}, interval);
}
resetTrackInputTimer(element) {
if (element.dataset.trackInput) {
window.clearTimeout(element.dataset.trackInput);
element.dataset.trackInput = null;
}
}
}
Snowboard.addPlugin('attributeRequest', AttributeRequest);

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 @@
!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}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){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)}();

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,68 @@
/**
* Allows attaching a loading class on elements that an AJAX request is targeting.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class AttachLoading extends Snowboard.Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ajaxStart: 'ajaxStart',
ajaxDone: 'ajaxDone',
};
}
ajaxStart(promise, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.add(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.add(this.getLoadingClass(request.element));
}
}
ajaxDone(data, request) {
if (!request.element) {
return;
}
if (request.element.tagName === 'FORM') {
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
if (loadElements.length > 0) {
loadElements.forEach((element) => {
element.classList.remove(this.getLoadingClass(element));
});
}
} else if (request.element.dataset.attachLoading !== undefined) {
request.element.classList.remove(this.getLoadingClass(request.element));
}
}
getLoadingClass(element) {
return (element.dataset.attachLoading !== undefined && element.dataset.attachLoading !== '')
? element.dataset.attachLoading
: 'wn-loading';
}
}

View File

@ -0,0 +1,129 @@
/**
* Provides flash messages for the CMS.
*
* Flash messages will pop up at the top center of the page and will remain for 7 seconds by default. Hovering over
* the message will reset and pause the timer. Clicking on the flash message will dismiss it.
*
* Arguments:
* - "message": The content of the flash message. HTML is accepted.
* - "type": The type of flash message. This is appended as a class to the flash message itself.
* - "duration": How long the flash message will stay visible for, in seconds. Default: 7 seconds.
*
* Usage:
* Snowboard.flash('This is a flash message', 'info', 8);
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Flash extends Snowboard.PluginBase {
/**
* Constructor.
*
* @param {Snowboard} snowboard
* @param {string} message
* @param {string} type
* @param {Number} duration
*/
constructor(snowboard, message, type, duration) {
super(snowboard);
this.message = message;
this.type = type || 'default';
this.duration = duration || 7;
this.clear();
this.timer = null;
this.flashTimer = null;
this.create();
}
/**
* Defines dependencies.
*
* @returns {string[]}
*/
dependencies() {
return ['transition'];
}
/**
* Destructor.
*
* This will ensure the flash message is removed and timeout is cleared if the module is removed.
*/
destructor() {
if (this.timer !== null) {
window.clearTimeout(this.timer);
}
if (this.flash) {
this.flashTimer.remove();
this.flash.remove();
this.flash = null;
this.flashTimer = null;
}
super.destructor();
}
/**
* Creates the flash message.
*/
create() {
this.flash = document.createElement('DIV');
this.flashTimer = document.createElement('DIV');
this.flash.innerHTML = this.message;
this.flash.classList.add('flash-message', this.type);
this.flashTimer.classList.add('flash-timer');
this.flash.removeAttribute('data-control');
this.flash.addEventListener('click', () => this.remove());
this.flash.addEventListener('mouseover', () => this.stopTimer());
this.flash.addEventListener('mouseout', () => this.startTimer());
// Add to body
this.flash.appendChild(this.flashTimer);
document.body.appendChild(this.flash);
this.snowboard.transition(this.flash, 'show', () => {
this.startTimer();
});
}
/**
* Removes the flash message.
*/
remove() {
this.stopTimer();
this.snowboard.transition(this.flash, 'hide', () => {
this.flash.remove();
this.flash = null;
this.destructor();
});
}
/**
* Clears all flash messages available on the page.
*/
clear() {
document.querySelectorAll('body > div.flash-message').forEach((element) => element.remove());
}
/**
* Starts the timer for this flash message.
*/
startTimer() {
this.timerTrans = this.snowboard.transition(this.flashTimer, 'timeout', null, `${this.duration}.0s`, true);
this.timer = window.setTimeout(() => this.remove(), this.duration * 1000);
}
/**
* Resets the timer for this flash message.
*/
stopTimer() {
if (this.timerTrans) {
this.timerTrans.cancel();
}
window.clearTimeout(this.timer);
}
}

View File

@ -0,0 +1,68 @@
/**
* Defines a default listener for flash events.
*
* Connects the Flash plugin to various events that use flash messages.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FlashListener extends Snowboard.Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['flash'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxErrorMessage: 'ajaxErrorMessage',
ajaxFlashMessages: 'ajaxFlashMessages',
};
}
/**
* Do flash messages for PHP flash responses.
*/
ready() {
document.querySelectorAll('[data-control="flash-message"]').forEach((element) => {
this.snowboard.flash(
element.innerHTML,
element.dataset.flashType,
element.dataset.flashDuration,
);
});
}
/**
* Shows a flash message for AJAX errors.
*
* @param {string} message
* @returns {Boolean}
*/
ajaxErrorMessage(message) {
this.snowboard.flash(message, 'error');
return false;
}
/**
* Shows flash messages returned directly from AJAX functionality.
*
* @param {Object} messages
*/
ajaxFlashMessages(messages) {
Object.entries(messages).forEach((entry) => {
const [cssClass, message] = entry;
this.snowboard.flash(message, cssClass);
});
return false;
}
}

View File

@ -0,0 +1,88 @@
/**
* Displays a stripe at the top of the page that indicates loading.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StripeLoader extends Snowboard.Singleton {
/**
* Defines dependenices.
*
* @returns {string[]}
*/
dependencies() {
return ['request'];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'ajaxStart',
};
}
ready() {
this.counter = 0;
this.createStripe();
}
ajaxStart(promise) {
this.show();
promise.catch(() => {
this.hide();
}).finally(() => {
this.hide();
});
}
createStripe() {
this.indicator = document.createElement('DIV');
this.stripe = document.createElement('DIV');
this.stripeLoaded = document.createElement('DIV');
this.indicator.classList.add('stripe-loading-indicator', 'loaded');
this.stripe.classList.add('stripe');
this.stripeLoaded.classList.add('stripe-loaded');
this.indicator.appendChild(this.stripe);
this.indicator.appendChild(this.stripeLoaded);
document.body.appendChild(this.indicator);
}
show() {
this.counter += 1;
const newStripe = this.stripe.cloneNode(true);
this.indicator.appendChild(newStripe);
this.stripe.remove();
this.stripe = newStripe;
if (this.counter > 1) {
return;
}
this.indicator.classList.remove('loaded');
document.body.classList.add('wn-loading');
}
hide(force) {
this.counter -= 1;
if (force === true) {
this.counter = 0;
}
if (this.counter <= 0) {
this.indicator.classList.add('loaded');
document.body.classList.remove('wn-loading');
}
}
}

View File

@ -0,0 +1,36 @@
/**
* Embeds the "extras" stylesheet into the page, if it is not loaded through the theme.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class StylesheetLoader extends Snowboard.Singleton {
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
};
}
ready() {
let stylesLoaded = false;
// Determine if stylesheet is already loaded
document.querySelectorAll('link[rel="stylesheet"]').forEach((css) => {
if (css.href.endsWith('/modules/system/assets/css/snowboard.extras.css')) {
stylesLoaded = true;
}
});
if (!stylesLoaded) {
const stylesheet = document.createElement('link');
stylesheet.setAttribute('rel', 'stylesheet');
stylesheet.setAttribute('href', '/modules/system/assets/css/snowboard.extras.css');
document.head.appendChild(stylesheet);
}
}
}

View File

@ -0,0 +1,172 @@
/**
* Provides transition support for elements.
*
* Transition allows CSS transitions to be controlled and callbacks to be run once completed. It works similar to Vue
* transitions with 3 stages of transition, and classes assigned to the element with the transition name suffixed with
* the stage of transition:
*
* - `in`: A class assigned to the element for the first frame of the transition, removed afterwards. This should be
* used to define the initial state of the transition.
* - `active`: A class assigned to the element for the duration of the transition. This should be used to define the
* transition itself.
* - `out`: A class assigned to the element after the first frame of the transition and kept to the end of the
* transition. This should define the end state of the transition.
*
* Usage:
* Snowboard.transition(document.element, 'transition', () => {
* console.log('Remove element after 7 seconds');
* this.remove();
* }, '7s');
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class Transition extends Snowboard.PluginBase {
/**
* Constructor.
*
* @param {Snowboard} snowboard
* @param {HTMLElement} element The element to transition
* @param {string} transition The name of the transition, this prefixes the stages of transition.
* @param {Function} callback An optional callback to call when the transition ends.
* @param {Number} duration An optional override on the transition duration. Must be specified as 's' (secs) or 'ms' (msecs).
* @param {Boolean} trailTo If true, the "out" class will remain after the end of the transition.
*/
constructor(snowboard, element, transition, callback, duration, trailTo) {
super(snowboard);
if (element instanceof HTMLElement === false) {
throw new Error('A HTMLElement must be provided for transitioning');
}
this.element = element;
if (typeof transition !== 'string') {
throw new Error('Transition name must be specified as a string');
}
this.transition = transition;
if (callback && typeof callback !== 'function') {
throw new Error('Callback must be a valid function');
}
this.callback = callback;
if (duration) {
this.duration = this.parseDuration(duration);
} else {
this.duration = null;
}
this.trailTo = (trailTo === true);
this.doTransition();
}
eventClasses(...args) {
const eventClasses = {
in: `${this.transition}-in`,
active: `${this.transition}-active`,
out: `${this.transition}-out`,
};
if (args.length === 0) {
return Object.values(eventClasses);
}
const returnClasses = [];
Object.entries(eventClasses).forEach((entry) => {
const [key, value] = entry;
if (args.indexOf(key) !== -1) {
returnClasses.push(value);
}
});
return returnClasses;
}
doTransition() {
// Add duration override
if (this.duration !== null) {
this.element.style.transitionDuration = this.duration;
}
this.resetClasses();
// Start transition - show "in" and "active" classes
this.eventClasses('in', 'active').forEach((eventClass) => {
this.element.classList.add(eventClass);
});
window.requestAnimationFrame(() => {
// Ensure a transition exists
if (window.getComputedStyle(this.element)['transition-duration'] !== '0s') {
// Listen for the transition to end
this.element.addEventListener('transitionend', () => this.onTransitionEnd(), {
once: true,
});
window.requestAnimationFrame(() => {
this.element.classList.remove(this.eventClasses('in')[0]);
this.element.classList.add(this.eventClasses('out')[0]);
});
} else {
this.resetClasses();
if (this.callback) {
this.callback.apply(this.element);
}
this.destructor();
}
});
}
onTransitionEnd() {
this.eventClasses('active', (!this.trailTo) ? 'out' : '').forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
if (this.callback) {
this.callback.apply(this.element);
}
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
this.destructor();
}
cancel() {
this.element.removeEventListener('transitionend', () => this.onTransitionEnd, {
once: true,
});
this.resetClasses();
// Remove duration override
if (this.duration !== null) {
this.element.style.transitionDuration = null;
}
this.destructor();
}
resetClasses() {
this.eventClasses().forEach((eventClass) => {
this.element.classList.remove(eventClass);
});
}
parseDuration(duration) {
const parsed = /^([0-9]+(\.[0-9]+)?)(m?s)?$/.exec(duration);
const amount = Number(parsed[1]);
const unit = (parsed[3] === 's')
? 'sec'
: 'msec';
return (unit === 'sec')
? `${amount * 1000}ms`
: `${Math.floor(amount)}ms`;
}
}

View File

@ -0,0 +1,258 @@
import PluginBase from '../abstracts/PluginBase';
import Singleton from '../abstracts/Singleton';
/**
* Plugin loader class.
*
* This is a provider (factory) class for a single plugin and provides the link between Snowboard framework functionality
* and the underlying plugin instances. It also provides some basic mocking of plugin methods for testing.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class PluginLoader {
/**
* Constructor.
*
* Binds the Winter framework to the instance.
*
* @param {string} name
* @param {Snowboard} snowboard
* @param {PluginBase} instance
*/
constructor(name, snowboard, instance) {
this.name = name;
this.snowboard = snowboard;
this.instance = instance;
this.instances = [];
this.singleton = instance.prototype instanceof Singleton;
this.mocks = {};
this.originalFunctions = {};
}
/**
* Determines if the current plugin has a specific method available.
*
* Returns false if the current plugin is a callback function.
*
* @param {string} methodName
* @returns {boolean}
*/
hasMethod(methodName) {
if (this.isFunction()) {
return false;
}
return (typeof this.instance.prototype[methodName] === 'function');
}
/**
* Calls a prototype method for a plugin. This should generally be used for "static" calls.
*
* @param {string} methodName
* @param {...} args
* @returns {any}
*/
callMethod(...parameters) {
if (this.isFunction()) {
return null;
}
const args = parameters;
const methodName = args.shift();
return this.instance.prototype[methodName](args);
}
/**
* Returns an instance of the current plugin.
*
* - If this is a callback function plugin, the function will be returned.
* - If this is a singleton, the single instance of the plugin will be returned.
*
* @returns {PluginBase|Function}
*/
getInstance(...parameters) {
if (this.isFunction()) {
return this.instance(...parameters);
}
if (!this.dependenciesFulfilled()) {
const unmet = this.getDependencies().filter((item) => !this.snowboard.getPluginNames().includes(item));
throw new Error(`The "${this.name}" plugin requires the following plugins: ${unmet.join(', ')}`);
}
if (this.isSingleton()) {
if (this.instances.length === 0) {
this.initialiseSingleton();
}
// Apply mocked methods
if (Object.keys(this.mocks).length > 0) {
Object.entries(this.originalFunctions).forEach((entry) => {
const [methodName, callback] = entry;
this.instances[0][methodName] = callback;
});
Object.entries(this.mocks).forEach((entry) => {
const [methodName, callback] = entry;
this.instances[0][methodName] = (...params) => callback(this, ...params);
});
}
return this.instances[0];
}
// Apply mocked methods to prototype
if (Object.keys(this.mocks).length > 0) {
Object.entries(this.originalFunctions).forEach((entry) => {
const [methodName, callback] = entry;
this.instance.prototype[methodName] = callback;
});
Object.entries(this.mocks).forEach((entry) => {
const [methodName, callback] = entry;
this.instance.prototype[methodName] = (...params) => callback(this, ...params);
});
}
const newInstance = new this.instance(this.snowboard, ...parameters);
newInstance.detach = () => this.instances.splice(this.instances.indexOf(newInstance), 1);
this.instances.push(newInstance);
return newInstance;
}
/**
* Gets all instances of the current plugin.
*
* If this plugin is a callback function plugin, an empty array will be returned.
*
* @returns {PluginBase[]}
*/
getInstances() {
if (this.isFunction()) {
return [];
}
return this.instances;
}
/**
* Determines if the current plugin is a simple callback function.
*
* @returns {boolean}
*/
isFunction() {
return (typeof this.instance === 'function' && this.instance.prototype instanceof PluginBase === false);
}
/**
* Determines if the current plugin is a singleton.
*
* @returns {boolean}
*/
isSingleton() {
return this.instance.prototype instanceof Singleton === true;
}
/**
* Initialises the singleton instance.
*
* @returns {void}
*/
initialiseSingleton() {
if (!this.isSingleton()) {
return;
}
const newInstance = new this.instance(this.snowboard);
newInstance.detach = () => this.instances.splice(this.instances.indexOf(newInstance), 1);
this.instances.push(newInstance);
}
/**
* Gets the dependencies of the current plugin.
*
* @returns {string[]}
*/
getDependencies() {
// Callback functions cannot have dependencies.
if (this.isFunction()) {
return [];
}
// No dependency method specified.
if (typeof this.instance.prototype.dependencies !== 'function') {
return [];
}
return this.instance.prototype.dependencies().map((item) => item.toLowerCase());
}
/**
* Determines if the current plugin has all its dependencies fulfilled.
*
* @returns {boolean}
*/
dependenciesFulfilled() {
const dependencies = this.getDependencies();
let fulfilled = true;
dependencies.forEach((plugin) => {
if (!this.snowboard.hasPlugin(plugin)) {
fulfilled = false;
}
});
return fulfilled;
}
/**
* Allows a method of an instance to be mocked for testing.
*
* This mock will be applied for the life of an instance. For singletons, the mock will be applied for the life
* of the page.
*
* Mocks cannot be applied to callback function plugins.
*
* @param {string} methodName
* @param {Function} callback
*/
mock(methodName, callback) {
if (this.isFunction()) {
return;
}
if (!this.instance.prototype[methodName]) {
throw new Error(`Function "${methodName}" does not exist and cannot be mocked`);
}
this.mocks[methodName] = callback;
this.originalFunctions[methodName] = this.instance.prototype[methodName];
if (this.isSingleton() && this.instances.length === 0) {
this.initialiseSingleton();
// Apply mocked method
this.instances[0][methodName] = (...parameters) => callback(this, ...parameters);
}
}
/**
* Removes a mock callback from future instances.
*
* @param {string} methodName
*/
unmock(methodName) {
if (this.isFunction()) {
return;
}
if (!this.mocks[methodName]) {
return;
}
if (this.isSingleton()) {
this.instances[0][methodName] = this.originalFunctions[methodName];
}
delete this.mocks[methodName];
delete this.originalFunctions[methodName];
}
}

View File

@ -0,0 +1,342 @@
import PluginBase from '../abstracts/PluginBase';
import Singleton from '../abstracts/Singleton';
import PluginLoader from './PluginLoader';
import Debounce from '../utilities/Debounce';
import JsonParser from '../utilities/JsonParser';
import Sanitizer from '../utilities/Sanitizer';
/**
* Snowboard - the Winter JavaScript framework.
*
* This class represents the base of a modern take on the Winter JS framework, being fully extensible and taking advantage
* of modern JavaScript features by leveraging the Laravel Mix compilation framework. It also is coded up to remove the
* dependency of jQuery.
*
* @copyright 2021 Winter.
* @author Ben Thomson <git@alfreido.com>
* @link https://wintercms.com/docs/snowboard/introduction
*/
export default class Snowboard {
/**
* Constructor.
*
* @param {boolean} autoSingletons Automatically load singletons when DOM is ready. Default: `true`.
* @param {boolean} debug Whether debugging logs should be shown. Default: `false`.
*/
constructor(autoSingletons, debug) {
this.debugEnabled = (typeof debug === 'boolean' && debug === true);
this.autoInitSingletons = (typeof autoSingletons === 'boolean' && autoSingletons === false);
this.plugins = {};
this.attachAbstracts();
this.loadUtilities();
this.initialise();
this.debug('Snowboard framework initialised');
}
attachAbstracts() {
this.PluginBase = PluginBase;
this.Singleton = Singleton;
}
loadUtilities() {
this.addPlugin('debounce', Debounce);
this.addPlugin('jsonParser', JsonParser);
this.addPlugin('sanitizer', Sanitizer);
}
/**
* Initialises the framework.
*
* Attaches a listener for the DOM being ready and triggers a global "ready" event for plugins to begin attaching
* themselves to the DOM.
*/
initialise() {
window.addEventListener('DOMContentLoaded', () => {
if (this.autoInitSingletons) {
this.initialiseSingletons();
}
this.globalEvent('ready');
});
}
/**
* Initialises an instance of every singleton.
*/
initialiseSingletons() {
Object.values(this.plugins).forEach((plugin) => {
if (plugin.isSingleton()) {
plugin.initialiseSingleton();
}
});
}
/**
* Adds a plugin to the framework.
*
* Plugins are the cornerstone for additional functionality for Snowboard. A plugin must either be an ES2015 class
* that extends the PluginBase or Singleton abstract classes, or a simple callback function.
*
* When a plugin is added, it is automatically assigned as a new magic method in the Snowboard class using the name
* parameter, and can be called via this method. This method will always be the "lowercase" version of this name.
*
* For example, if a plugin is assigned to the name "myPlugin", it can be called via `Snowboard.myplugin()`.
*
* @param {string} name
* @param {PluginBase|Function} instance
*/
addPlugin(name, instance) {
const lowerName = name.toLowerCase();
if (this.hasPlugin(lowerName)) {
throw new Error(`A plugin called "${name}" is already registered.`);
}
if (typeof instance !== 'function' && instance instanceof PluginBase === false) {
throw new Error('The provided plugin must extend the PluginBase class, or must be a callback function.');
}
if (this[name] !== undefined || this[lowerName] !== undefined) {
throw new Error('The given name is already in use for a property or method of the Snowboard class.');
}
this.plugins[lowerName] = new PluginLoader(lowerName, this, instance);
const callback = (...parameters) => this.plugins[lowerName].getInstance(...parameters);
this[name] = callback;
this[lowerName] = callback;
this.debug(`Plugin "${name}" registered`);
}
/**
* Removes a plugin.
*
* Removes a plugin from Snowboard, calling the destructor method for all active instances of the plugin.
*
* @param {string} name
* @returns {void}
*/
removePlugin(name) {
const lowerName = name.toLowerCase();
if (!this.hasPlugin(lowerName)) {
/* develblock:start */
this.debug(`Plugin "${name}" already removed`);
/* develblock:end */
return;
}
// Call destructors for all instances
this.plugins[lowerName].getInstances().forEach((instance) => {
instance.destructor();
});
delete this.plugins[lowerName];
delete this[lowerName];
this.debug(`Plugin "${name}" removed`);
}
/**
* Determines if a plugin has been registered and is active.
*
* A plugin that is still waiting for dependencies to be registered will not be active.
*
* @param {string} name
* @returns {boolean}
*/
hasPlugin(name) {
const lowerName = name.toLowerCase();
return (this.plugins[lowerName] !== undefined);
}
/**
* Returns an array of registered plugins as PluginLoader objects.
*
* @returns {PluginLoader[]}
*/
getPlugins() {
return this.plugins;
}
/**
* Returns an array of registered plugins, by name.
*
* @returns {string[]}
*/
getPluginNames() {
return Object.keys(this.plugins);
}
/**
* Returns a PluginLoader object of a given plugin.
*
* @returns {PluginLoader}
*/
getPlugin(name) {
if (!this.hasPlugin(name)) {
throw new Error(`No plugin called "${name}" has been registered.`);
}
return this.plugins[name];
}
/**
* Finds all plugins that listen to the given event.
*
* This works for both normal and promise events. It does NOT check that the plugin's listener actually exists.
*
* @param {string} eventName
* @returns {string[]} The name of the plugins that are listening to this event.
*/
listensToEvent(eventName) {
const plugins = [];
Object.entries(this.plugins).forEach((entry) => {
const [name, plugin] = entry;
if (plugin.isFunction()) {
return;
}
if (!plugin.hasMethod('listens')) {
return;
}
const listeners = plugin.callMethod('listens');
if (typeof listeners[eventName] === 'string') {
plugins.push(name);
}
});
return plugins;
}
/**
* Calls a global event to all registered plugins.
*
* If any plugin returns a `false`, the event is considered cancelled.
*
* @param {string} eventName
* @returns {boolean} If event was not cancelled
*/
globalEvent(eventName, ...parameters) {
this.debug(`Calling global event "${eventName}"`);
// Find out which plugins listen to this event - if none listen to it, return true.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global event "${eventName}"`);
return true;
}
this.debug(`Listeners found for global event "${eventName}": ${listeners.join(', ')}`);
let cancelled = false;
listeners.forEach((name) => {
const plugin = this.getPlugin(name);
if (plugin.isFunction()) {
return;
}
if (plugin.isSingleton() && plugin.getInstances().length === 0) {
plugin.initialiseSingleton();
}
const listenMethod = plugin.callMethod('listens')[eventName];
// Call event handler methods for all plugins, if they have a method specified for the event.
plugin.getInstances().forEach((instance) => {
// If a plugin has cancelled the event, no further plugins are considered.
if (cancelled) {
return;
}
if (!instance[listenMethod]) {
throw new Error(`Missing "${listenMethod}" method in "${name}" plugin`);
}
if (instance[listenMethod](...parameters) === false) {
cancelled = true;
this.debug(`Global event "${eventName}" cancelled by "${name}" plugin`);
}
});
});
return !cancelled;
}
/**
* Calls a global event to all registered plugins, expecting a Promise to be returned by all.
*
* This collates all plugins responses into one large Promise that either expects all to be resolved, or one to reject.
* If no listeners are found, a resolved Promise is returned.
*
* @param {string} eventName
*/
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.
const listeners = this.listensToEvent(eventName);
if (listeners.length === 0) {
this.debug(`No listeners found for global promise event "${eventName}"`);
return Promise.resolve();
}
this.debug(`Listeners found for global promise event "${eventName}": ${listeners.join(', ')}`);
const promises = [];
listeners.forEach((name) => {
const plugin = this.getPlugin(name);
if (plugin.isFunction()) {
return;
}
if (plugin.isSingleton() && plugin.getInstances().length === 0) {
plugin.initialiseSingleton();
}
const listenMethod = plugin.callMethod('listens')[eventName];
// Call event handler methods for all plugins, if they have a method specified for the event.
plugin.getInstances().forEach((instance) => {
const instancePromise = instance[listenMethod](...parameters);
if (instancePromise instanceof Promise === false) {
return;
}
promises.push(instancePromise);
});
});
if (promises.length === 0) {
return Promise.resolve();
}
return Promise.all(promises);
}
/**
* Log a debug message.
*
* These messages are only shown when debugging is enabled.
*
* @returns {void}
*/
debug(...parameters) {
if (!this.debugEnabled) {
return;
}
/* eslint-disable */
console.groupCollapsed('%c[Snowboard]', 'color: rgb(45, 167, 199); font-weight: normal;', ...parameters);
console.trace();
console.groupEnd();
/* eslint-enable */
}
}

View File

@ -0,0 +1,37 @@
{
"name": "@wintercms/snowboard",
"version": "1.0.0",
"description": "The next evolution for the Winter JavaScript framework.",
"main": "snowboard.base.js",
"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/docs/snowboard/introduction",
"dependencies": {
"laravel-mix": "^6.0.34",
"laravel-mix-polyfill": "^3.0.1"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"eslint": "^8.6.0",
"eslint-plugin-import": "^2.25.4",
"eslint-config-airbnb-base": "^15.0.0"
}
}

View File

@ -0,0 +1,10 @@
import Snowboard from './main/Snowboard';
((window) => {
const snowboard = new Snowboard(true, true);
// Cover all aliases
window.snowboard = snowboard;
window.Snowboard = snowboard;
window.SnowBoard = snowboard;
})(window);

View File

@ -0,0 +1,10 @@
import Snowboard from './main/Snowboard';
((window) => {
const snowboard = new Snowboard();
// Cover all aliases
window.snowboard = snowboard;
window.Snowboard = snowboard;
window.SnowBoard = snowboard;
})(window);

View File

@ -0,0 +1,15 @@
import Flash from './extras/Flash';
import FlashListener from './extras/FlashListener';
import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
import StylesheetLoader from './extras/StylesheetLoader';
((Snowboard) => {
Snowboard.addPlugin('extrasStyles', StylesheetLoader);
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);
Snowboard.addPlugin('flashListener', FlashListener);
Snowboard.addPlugin('attachLoading', AttachLoading);
Snowboard.addPlugin('stripeLoader', StripeLoader);
})(window.Snowboard);

View File

@ -0,0 +1,18 @@
export default (fn) => {
// This holds the requestAnimationFrame reference, so we can cancel it if we wish
let frame;
// The debounce function returns a new function that can receive a variable number of arguments
return (...params) => {
// If the frame variable has been defined, clear it now, and queue for next frame
if (frame) {
cancelAnimationFrame(frame);
}
// Queue our function call for the next frame
frame = requestAnimationFrame(() => {
// Call our function and pass any params we received
fn(...params);
});
};
};

View File

@ -0,0 +1,387 @@
import Singleton from '../abstracts/Singleton';
export default class JsonParser extends Singleton {
constructor(snowboard) {
super(snowboard);
// Add to global function for backwards compatibility
window.wnJSON = (json) => this.parse(json);
window.ocJSON = window.wnJSON;
}
parse(str) {
const jsonString = this.parseString(str);
return JSON.parse(jsonString);
}
parseString(value) {
let str = value.trim();
if (!str.length) {
throw new Error('Broken JSON object.');
}
let result = '';
let type = null;
let key = null;
let body = '';
/*
* the mistake ','
*/
while (str && str[0] === ',') {
str = str.substr(1);
}
/*
* string
*/
if (str[0] === '"' || str[0] === '\'') {
if (str[str.length - 1] !== str[0]) {
throw new Error('Invalid string JSON object.');
}
body = '"';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === '\\') {
if (str[i + 1] === '\'') {
body += str[i + 1];
} else {
body += str[i];
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[0]) {
body += '"';
return body;
} else if (str[i] === '"') {
body += '\\"';
} else {
body += str[i];
}
}
throw new Error('Invalid string JSON object.');
}
/*
* boolean
*/
if (str === 'true' || str === 'false') {
return str;
}
/*
* null
*/
if (str === 'null') {
return 'null';
}
/*
* number
*/
const num = parseFloat(str);
if (!Number.isNaN(num)) {
return num.toString();
}
/*
* object
*/
if (str[0] === '{') {
type = 'needKey';
key = null;
result = '{';
for (let i = 1; i < str.length; i += 1) {
if (this.isBlankChar(str[i])) {
/* eslint-disable-next-line */
continue;
}
if (type === 'needKey' && (str[i] === '"' || str[i] === '\'')) {
key = this.parseKey(str, i + 1, str[i]);
result += `"${key}"`;
i += key.length;
i += 1;
type = 'afterKey';
} else if (type === 'needKey' && this.canBeKeyHead(str[i])) {
key = this.parseKey(str, i);
result += '"';
result += key;
result += '"';
i += key.length - 1;
type = 'afterKey';
} else if (type === 'afterKey' && str[i] === ':') {
result += ':';
type = ':';
} else if (type === ':') {
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody' || type === 'needKey') {
let last = i;
while (str[last] === ',' || this.isBlankChar(str[last])) {
last += 1;
}
if (str[last] === '}' && last === str.length - 1) {
while (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += '}';
return result;
}
if (last !== i && result !== '{') {
result += ',';
type = 'needKey';
i = last - 1;
}
}
}
throw new Error(`Broken JSON object near ${result}`);
}
/*
* array
*/
if (str[0] === '[') {
result = '[';
type = 'needBody';
for (let i = 1; i < str.length; i += 1) {
if (str[i] === ' ' || str[i] === '\n' || str[i] === '\t') {
/* eslint-disable-next-line */
continue;
} else if (type === 'needBody') {
if (str[i] === ',') {
result += 'null,';
/* eslint-disable-next-line */
continue;
}
if (str[i] === ']' && i === str.length - 1) {
if (result[result.length - 1] === ',') {
result = result.substr(0, result.length - 1);
}
result += ']';
return result;
}
body = this.getBody(str, i);
i = i + body.originLength - 1;
result += this.parseString(body.body);
type = 'afterBody';
} else if (type === 'afterBody') {
if (str[i] === ',') {
result += ',';
type = 'needBody';
// deal with mistake ","
while (str[i + 1] === ',' || this.isBlankChar(str[i + 1])) {
if (str[i + 1] === ',') {
result += 'null,';
}
i += 1;
}
} else if (str[i] === ']' && i === str.length - 1) {
result += ']';
return result;
}
}
}
throw new Error(`Broken JSON array near ${result}`);
}
return '';
}
getBody(str, pos) {
let body = '';
// parse string body
if (str[pos] === '"' || str[pos] === '\'') {
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
if (str[i] === '\\') {
body += str[i];
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === str[pos]) {
body += str[pos];
return {
originLength: body.length,
body,
};
} else {
body += str[i];
}
}
throw new Error(`Broken JSON string body near ${body}`);
}
// parse true / false
if (str[pos] === 't') {
if (str.indexOf('true', pos) === pos) {
return {
originLength: 'true'.length,
body: 'true',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
if (str[pos] === 'f') {
if (str.indexOf('f', pos) === pos) {
return {
originLength: 'false'.length,
body: 'false',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse null
if (str[pos] === 'n') {
if (str.indexOf('null', pos) === pos) {
return {
originLength: 'null'.length,
body: 'null',
};
}
throw new Error(`Broken JSON boolean body near ${str.substr(0, pos + 10)}`);
}
// parse number
if (str[pos] === '-' || str[pos] === '+' || str[pos] === '.' || (str[pos] >= '0' && str[pos] <= '9')) {
body = '';
for (let i = pos; i < str.length; i += 1) {
if (str[i] === '-' || str[i] === '+' || str[i] === '.' || (str[i] >= '0' && str[i] <= '9')) {
body += str[i];
} else {
return {
originLength: body.length,
body,
};
}
}
throw new Error(`Broken JSON number body near ${body}`);
}
// parse object
if (str[pos] === '{' || str[pos] === '[') {
const stack = [
str[pos],
];
body = str[pos];
for (let i = pos + 1; i < str.length; i += 1) {
body += str[i];
if (str[i] === '\\') {
if (i + 1 < str.length) {
body += str[i + 1];
}
i += 1;
} else if (str[i] === '"') {
if (stack[stack.length - 1] === '"') {
stack.pop();
} else if (stack[stack.length - 1] !== '\'') {
stack.push(str[i]);
}
} else if (str[i] === '\'') {
if (stack[stack.length - 1] === '\'') {
stack.pop();
} else if (stack[stack.length - 1] !== '"') {
stack.push(str[i]);
}
} else if (stack[stack.length - 1] !== '"' && stack[stack.length - 1] !== '\'') {
if (str[i] === '{') {
stack.push('{');
} else if (str[i] === '}') {
if (stack[stack.length - 1] === '{') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
} else if (str[i] === '[') {
stack.push('[');
} else if (str[i] === ']') {
if (stack[stack.length - 1] === '[') {
stack.pop();
} else {
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
}
}
if (!stack.length) {
return {
originLength: i - pos,
body,
};
}
}
throw new Error(`Broken JSON ${(str[pos] === '{' ? 'object' : 'array')} body near ${body}`);
}
throw new Error(`Broken JSON body near ${str.substr((pos - 5 >= 0) ? pos - 5 : 0, 50)}`);
}
parseKey(str, pos, quote) {
let key = '';
for (let i = pos; i < str.length; i += 1) {
if (quote && quote === str[i]) {
return key;
}
if (!quote && (str[i] === ' ' || str[i] === ':')) {
return key;
}
key += str[i];
if (str[i] === '\\' && i + 1 < str.length) {
key += str[i + 1];
i += 1;
}
}
throw new Error(`Broken JSON syntax near ${key}`);
}
canBeKeyHead(ch) {
if (ch[0] === '\\') {
return false;
}
if ((ch[0] >= 'a' && ch[0] <= 'z') || (ch[0] >= 'A' && ch[0] <= 'Z') || ch[0] === '_') {
return true;
}
if (ch[0] >= '0' && ch[0] <= '9') {
return true;
}
if (ch[0] === '$') {
return true;
}
if (ch.charCodeAt(0) > 255) {
return true;
}
return false;
}
isBlankChar(ch) {
return ch === ' ' || ch === '\n' || ch === '\t';
}
}

View File

@ -0,0 +1,58 @@
import Singleton from '../abstracts/Singleton';
export default class Sanitizer extends Singleton {
constructor(snowboard) {
super(snowboard);
// Add to global function for backwards compatibility
window.wnSanitize = (html) => this.sanitize(html);
window.ocSanitize = window.wnSanitize;
}
sanitize(html, bodyOnly) {
const parser = new DOMParser();
const dom = parser.parseFromString(html, 'text/html');
const returnBodyOnly = (bodyOnly !== undefined && typeof bodyOnly === 'boolean')
? bodyOnly
: true;
this.sanitizeNode(dom.getRootNode());
return (returnBodyOnly) ? dom.body.innerHTML : dom.innerHTML;
}
sanitizeNode(node) {
if (node.tagName === 'SCRIPT') {
node.remove();
return;
}
this.trimAttributes(node);
const children = Array.from(node.children);
children.forEach((child) => {
this.sanitizeNode(child);
});
}
trimAttributes(node) {
if (!node.attributes) {
return;
}
for (let i = 0; i < node.attributes.length; i += 1) {
const attrName = node.attributes.item(i).name;
const attrValue = node.attributes.item(i).value;
/*
* remove attributes where the names start with "on" (for example: onload, onerror...)
* remove attributes where the value starts with the "javascript:" pseudo protocol (for example href="javascript:alert(1)")
*/
/* eslint-disable-next-line */
if (attrName.indexOf('on') === 0 || attrValue.indexOf('javascript:') === 0) {
node.removeAttribute(attrName);
}
}
}
}

View File

@ -0,0 +1,17 @@
const mix = require('laravel-mix');
require('laravel-mix-polyfill');
mix.setPublicPath(__dirname);
// Compile Snowboard framework
mix
.js('./snowboard.base.js', './build/snowboard.base.js')
.js('./snowboard.base.debug.js', './build/snowboard.base.debug.js')
.js('./ajax/Request.js', './build/snowboard.request.js')
.js('./ajax/handlers/AttributeRequest.js', './build/snowboard.data-attr.js')
.js('./snowboard.extras.js', './build/snowboard.extras.js')
.polyfill({
enabled: mix.inProduction(),
useBuiltIns: 'usage',
targets: '> 0.5%, last 2 versions, not dead, Firefox ESR, not ie > 0',
});

View File

@ -0,0 +1,259 @@
@import "../../../backend/assets/less/core/boot.less";
//
// Stripe loading indicator
// --------------------------------------------------
body.wn-loading, body.wn-loading *,
body.oc-loading, body.oc-loading * {
cursor: wait !important;
}
@stripe-loader-color: #0090c0;
@stripe-loader-height: 5px;
.stripe-loading-indicator {
height: @stripe-loader-height;
background: transparent;
position: fixed;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
z-index: 2000;
.stripe, .stripe-loaded {
height: @stripe-loader-height;
display: block;
background: @stripe-loader-color;
position: absolute;
.box-shadow(~"inset 0 1px 1px -1px #FFF, inset 0 -1px 1px -1px #FFF");
}
.stripe {
width: 100%;
.animation(wn-infinite-loader 60s linear);
}
.stripe-loaded {
width: 100%;
transform: translate3d(-100%, 0, 0);
.opacity(0);
}
&.loaded {
.opacity(0);
.transition(opacity .4s linear);
.transition-delay(.3s);
.stripe {
animation-play-state: paused;
}
.stripe-loaded {
.opacity(1);
transform: translate3d(0, 0, 0);
.transition(transform .3s linear);
}
}
&.hide {
display: none;
}
}
//
// Flash Messages
// --------------------------------------------------
@color-flash-success-bg: #8da85e;
@color-flash-error-bg: #cc3300;
@color-flash-warning-bg: #f0ad4e;
@color-flash-info-bg: #5fb6f5;
@color-flash-text: #ffffff;
body > div.flash-message {
position: fixed;
width: 500px;
left: 50%;
top: 13px;
margin-left: -250px;
color: @color-flash-text;
font-size: 14px;
padding: 10px 30px 14px 15px;
z-index: @zindex-flashmessage;
word-wrap: break-word;
text-shadow: 0 -1px 0px rgba(0,0,0,.15);
text-align: center;
.box-shadow(@overlay-box-shadow);
.border-radius(@border-radius-base);
cursor: pointer;
.flash-timer {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background: #ffffff;
opacity: 0.5;
.border-radius(@border-radius-base);
&.timeout-active {
.transition(~'width 6s linear');
}
&.timeout-in {
width: 100%;
}
&.timeout-out {
width: 0%;
}
}
&.show-active, &.hide-active {
.transition(~'all 0.5s, width 0s');
}
&.show-in, &.hide-out {
.opacity(0);
.transform(~'scale(0.9)');
}
&.show-out, &.hide-in {
.opacity(1);
.transform(~'scale(1)');
}
&.success { background: @color-flash-success-bg; }
&.error { background: @color-flash-error-bg; }
&.warning { background: @color-flash-warning-bg; }
&.info { background: @color-flash-info-bg; }
}
@media (max-width: @screen-sm) {
body > div.flash-message {
left: 10px;
right: 10px;
top: 10px;
margin-left: 0;
width: auto;
}
}
//
// Form Validation
// --------------------------------------------------
[data-request][data-request-validate] [data-validate-for],
[data-request][data-request-validate] [data-validate-error] {
&:not(.visible) {
display: none;
}
}
//
// Element Loader
// --------------------------------------------------
a.wn-loading, button.wn-loading, span.wn-loading,
a.oc-loading, button.oc-loading, span.oc-loading {
&:after {
content: '';
display: inline-block;
vertical-align: middle;
margin-left: .4em;
height: 1em;
width: 1em;
animation: wn-rotate-loader 0.8s infinite linear;
border: .2em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
.opacity(.5);
}
}
@-moz-keyframes wn-rotate-loader {
0% { -moz-transform: rotate(0deg); }
100% { -moz-transform: rotate(360deg); }
}
@-webkit-keyframes wn-rotate-loader {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@-o-keyframes wn-rotate-loader {
0% { -o-transform: rotate(0deg); }
100% { -o-transform: rotate(360deg); }
}
@-ms-keyframes wn-rotate-loader {
0% { -ms-transform: rotate(0deg); }
100% { -ms-transform: rotate(360deg); }
}
@keyframes wn-rotate-loader {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@-moz-keyframes oc-rotate-loader {
0% { -moz-transform: rotate(0deg); }
100% { -moz-transform: rotate(360deg); }
}
@-webkit-keyframes oc-rotate-loader {
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@-o-keyframes oc-rotate-loader {
0% { -o-transform: rotate(0deg); }
100% { -o-transform: rotate(360deg); }
}
@-ms-keyframes oc-rotate-loader {
0% { -ms-transform: rotate(0deg); }
100% { -ms-transform: rotate(360deg); }
}
@keyframes oc-rotate-loader {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
//
// Infinite loading animation
// --------------------------------------------------
@startVal: 100%;
@start: 0;
.infinite-class (@index, @val) when (@index < 101) {
@tmpSelector: ~"@{index}%";
@{tmpSelector} { transform: translateX(-@val); }
.infinite-class(@index + 10, @val - @val / 2);
}
@-moz-keyframes wn-infinite-loader {
.infinite-class(@start, @startVal);
}
@-webkit-keyframes wn-infinite-loader {
.infinite-class(@start, @startVal);
}
@-o-keyframes wn-infinite-loader {
.infinite-class(@start, @startVal);
}
@-ms-keyframes wn-infinite-loader {
.infinite-class(@start, @startVal);
}
@keyframes wn-infinite-loader {
.infinite-class(@start, @startVal);
}
@-moz-keyframes oc-infinite-loader {
.infinite-class(@start, @startVal);
}
@-webkit-keyframes oc-infinite-loader {
.infinite-class(@start, @startVal);
}
@-o-keyframes oc-infinite-loader {
.infinite-class(@start, @startVal);
}
@-ms-keyframes oc-infinite-loader {
.infinite-class(@start, @startVal);
}
@keyframes oc-infinite-loader {
.infinite-class(@start, @startVal);
}

View File

@ -0,0 +1,185 @@
<?php namespace System\Classes;
use File;
use ApplicationException;
use Cms\Classes\Theme;
use Winter\Storm\Filesystem\PathResolver;
/**
* Mix assets using Laravel Mix for Node.js compilation and processing.
*
* This works similar to the `System\Classes\CombineAssets` class in that it allows modules, plugins and themes to
* register configurations that will be passed on to Laravel Mix and Node.js for compilation and processing.
*
* @package winter\wn-system-module
* @author Ben Thomson <git@alfreido.com>, Jack Wilkinson <jax@jaxwilko.com>
* @author Winter CMS
*/
class MixAssets
{
use \Winter\Storm\Support\Traits\Singleton;
/**
* The filename that stores the package definition.
*
* @var string
*/
protected $packageJson = 'package.json';
/**
* A list of packages registered for mixing.
*
* @var array
*/
protected $packages = [];
/**
* Registered callbacks.
*
* @var array
*/
protected static $callbacks = [];
/**
* Constructor.
*
* @return void
*/
public function init()
{
/*
* Get packages registered in plugins.
*
* In the Plugin.php file for your plugin, you can define the "registerMixPackages" method and return an array,
* with the name of the package being the key, and the build config path - relative to the plugin directory - as
* the value.
*
* Example:
*
* public function registerMixPackages()
* {
* return [
* 'package-name-1' => 'winter-mix.js',
* 'package-name-2' => 'assets/js/build.js',
* ];
* }
*/
$packages = PluginManager::instance()->getRegistrationMethodValues('registerMixPackages');
if (count($packages)) {
foreach ($packages as $pluginCode => $packageArray) {
if (!is_array($packageArray)) {
continue;
}
foreach ($packageArray as $name => $package) {
$this->registerPackage($name, PluginManager::instance()->getPluginPath($pluginCode), $package);
}
}
}
// Allow current theme to define mix assets
$theme = Theme::getActiveTheme();
if (!is_null($theme)) {
$mix = $theme->getConfigValue('mix', []);
if (count($mix)) {
foreach ($mix as $name => $file) {
$path = PathResolver::resolve($theme->getPath() . '/' . $file);
$pinfo = pathinfo($path);
$this->registerPackage($name, $pinfo['dirname'], $pinfo['basename']);
}
}
}
}
/**
* Registers a callback for processing.
*
* @param callable $callback
* @return void
*/
public static function registerCallback(callable $callback)
{
static::$callbacks[] = $callback;
}
/**
* Calls the deferred callbacks.
*
* @return void
*/
public function fireCallbacks()
{
// Call callbacks
foreach (static::$callbacks as $callback) {
$callback($this);
}
}
/**
* Returns the count of packages registered.
*
* @return int
*/
public function getPackageCount()
{
return count($this->packages);
}
/**
* Returns all packages registered.
*
* @return array
*/
public function getPackages()
{
return $this->packages;
}
/**
* Registers an entity as a package for mixing.
*
* Entities can include plugins, components, themes, modules and much more.
*
* The name of the package is an alias that can be used to reference this package in other methods within this
* class.
*
* By default, the MixAssets class will look for a `package.json` file for Node dependencies, and a `
*
* @param string $name
* @param string $path
* @param string $packageJson
* @param string $mixJson
* @return void
*/
public function registerPackage($name, $path, $mixJs = 'winter-mix.js')
{
// Require JS file for $mixJs
$extension = File::extension($mixJs);
if ($extension !== 'js') {
throw new ApplicationException(
sprintf('The mix configuration for package "%s" must be a JavaScript file ending with .js', $name)
);
}
$path = rtrim(File::symbolizePath($path), '/\\');
if (!File::exists($path . DIRECTORY_SEPARATOR . $this->packageJson)) {
throw new ApplicationException(
sprintf('Missing file "%s" in path "%s" for package "%s"', $this->packageJson, $path, $name)
);
}
if (!File::exists($path . DIRECTORY_SEPARATOR . $mixJs)) {
throw new ApplicationException(
sprintf('Missing file "%s" in path "%s" for package "%s"', $mixJs, $path, $name)
);
}
$this->packages[$name] = [
'path' => $path,
'package' => $path . DIRECTORY_SEPARATOR . $this->packageJson,
'mix' => $path . DIRECTORY_SEPARATOR . $mixJs,
];
}
}

View File

@ -0,0 +1,139 @@
<?php namespace System\Console;
use File;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Process\Process;
use System\Classes\MixAssets;
class MixCompile extends Command
{
/**
* @var string The console command name.
*/
protected $name = 'mix:compile';
/**
* @var string The console command description.
*/
protected $description = 'Mix and compile assets';
/**
* Execute the console command.
* @return int
*/
public function handle(): int
{
if ($this->option('npm')) {
$this->npmPath = $this->option('npm');
}
$mixedAssets = MixAssets::instance();
$mixedAssets->fireCallbacks();
$packages = $mixedAssets->getPackages();
if (count($this->option('package')) && count($packages)) {
foreach (array_keys($packages) as $name) {
if (!in_array($name, $this->option('package'))) {
unset($packages[$name]);
}
}
}
if (!count($packages)) {
if (count($this->option('package'))) {
$this->error('No registered packages matched the requested packages for compilation.');
return 1;
} else {
$this->info('No packages registered for mixing.');
return 0;
}
}
foreach ($packages as $name => $package) {
$this->info(
sprintf('Mixing package "%s"', $name)
);
if ($this->mixPackage($package) !== 0) {
$this->error(
sprintf('Unable to compile package "%s"', $name)
);
}
}
return 0;
}
protected function mixPackage($package)
{
$this->createWebpackConfig($package['path'], $package['mix']);
$command = $this->createCommand($package);
$process = new Process(
$command,
$package['path'],
['NODE_ENV' => $this->option('production', false) ? 'production' : 'development'],
null,
null
);
try {
$process->setTty(true);
} catch (\Throwable $e) {
// This will fail on unsupported systems
}
$exitCode = $process->run(function ($status, $stdout) {
if ($this->option('verbose')) {
$this->getOutput()->write($stdout);
}
});
$this->removeWebpackConfig($package['path']);
return $exitCode;
}
protected function createCommand($package)
{
return [
$package['path'] . implode(DIRECTORY_SEPARATOR, ['', 'node_modules', 'webpack', 'bin', 'webpack.js']),
'--progress',
'--config=' . $package['path'] . DIRECTORY_SEPARATOR . 'mix.webpack.js',
];
}
protected function createWebpackConfig($path, $mixPath)
{
$fixture = File::get(__DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'mix.webpack.js.fixture');
$config = str_replace(
['%base%', '%notificationInject%', '%mixConfigPath%'],
[$path, '', $mixPath],
$fixture
);
File::put($path . DIRECTORY_SEPARATOR . 'mix.webpack.js', $config);
}
protected function removeWebpackConfig($path)
{
if (File::exists($path . DIRECTORY_SEPARATOR . 'mix.webpack.js')) {
File::delete($path . DIRECTORY_SEPARATOR . 'mix.webpack.js');
}
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['npm', null, InputOption::VALUE_REQUIRED, 'Defines a custom path to the "npm" binary'],
['production', 'f', InputOption::VALUE_NONE, 'Run a "production" compilation'],
['package', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Defines one or more packages to compile'],
];
}
}

View File

@ -0,0 +1,101 @@
<?php namespace System\Console;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Process\Process;
use System\Classes\MixAssets;
class MixInstall extends Command
{
/**
* @var string The console command name.
*/
protected $name = 'mix:install';
/**
* @var string The console command description.
*/
protected $description = 'Install Node.js dependencies required for mixed assets';
/**
* @var string The path to the "npm" executable.
*/
protected $npmPath = 'npm';
/**
* Execute the console command.
* @return int
*/
public function handle(): int
{
if ($this->option('npm')) {
$this->npmPath = $this->option('npm');
}
$mixedAssets = MixAssets::instance();
$mixedAssets->fireCallbacks();
if ($mixedAssets->getPackageCount() === 0) {
$this->info('No packages registered for mixing.');
return 0;
}
if (!version_compare($this->getNpmVersion(), '6', '>')) {
$this->error('"npm" version 7 or above must be installed to run this command.');
return 1;
}
// Process each package
foreach ($mixedAssets->getPackages() as $name => $package) {
$this->info(
sprintf('Installing dependencies for package "%s"', $name)
);
if ($this->installPackageDeps($package) !== 0) {
$this->error(
sprintf('Unable to install dependencies for package "%s"', $name)
);
}
}
return 0;
}
/**
* Installs the dependencies for the given package.
*
* @param string $package
* @return int
*/
protected function installPackageDeps($package)
{
$process = new Process(['npm', 'i'], $package['path']);
$process->run(function ($status, $stdout) {
$this->getOutput()->write($stdout);
});
return $process->getExitCode();
}
/**
* Gets the install NPM version.
*
* @return string
*/
protected function getNpmVersion()
{
$process = new Process(['npm', '--version']);
$process->run();
return $process->getOutput();
}
/**
* Get the console command options.
* @return array
*/
protected function getOptions()
{
return [
['npm', null, InputOption::VALUE_REQUIRED, 'Defines a custom path to the "npm" binary'],
];
}
}

View File

@ -0,0 +1,46 @@
<?php namespace System\Console;
use File;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use System\Classes\MixAssets;
class MixList extends MixCompile
{
/**
* @var string The console command name.
*/
protected $name = 'mix:list';
/**
* @var string The console command description.
*/
protected $description = 'List all registered Mix packages in this project.';
public function handle(): int
{
$this->line('');
$mixedAssets = MixAssets::instance();
$mixedAssets->fireCallbacks();
$packages = $mixedAssets->getPackages();
if (count($packages) === 0) {
$this->info('No packages have been registered.');
return 0;
}
$this->info('Packages registered:');
$this->line('');
foreach ($packages as $name => $package) {
$this->info($name);
$this->line(' Path: ' . $package['path']);
$this->line(' Configuration: ' . $package['mix']);
}
$this->line('');
return 0;
}
}

View File

@ -0,0 +1,95 @@
<?php namespace System\Console;
use File;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use System\Classes\MixAssets;
class MixWatch extends MixCompile
{
/**
* @var string The console command name.
*/
protected $name = 'mix:watch';
/**
* @var string The console command description.
*/
protected $description = 'Mix and compile assets on-the-fly as changes are made.';
public function handle(): int
{
if ($this->option('npm')) {
$this->npmPath = $this->option('npm');
}
$mixedAssets = MixAssets::instance();
$mixedAssets->fireCallbacks();
$packages = $mixedAssets->getPackages();
$name = $this->argument('package');
if (!in_array($name, array_keys($packages))) {
$this->error(
sprintf('Package "%s" is not a registered package.', $name)
);
return 1;
}
$package = $packages[$name];
$this->info(
sprintf('Watching package "%s" for changes', $name)
);
if ($this->mixPackage($package) !== 0) {
$this->error(
sprintf('Unable to compile package "%s"', $name)
);
return 1;
}
return 0;
}
protected function createCommand($package)
{
$command = parent::createCommand($package);
$command[] = '--watch';
return $command;
}
protected function createWebpackConfig($path, $mixPath)
{
$fixture = File::get(__DIR__ . DIRECTORY_SEPARATOR . 'fixtures' . DIRECTORY_SEPARATOR . 'mix.webpack.js.fixture');
$config = str_replace(
['%base%', '%notificationInject%', '%mixConfigPath%'],
[$path, 'mix._api.disableNotifications();', $mixPath],
$fixture
);
File::put($path . DIRECTORY_SEPARATOR . 'mix.webpack.js', $config);
}
/**
* @inheritDoc
*/
protected function getArguments()
{
return [
['package', InputArgument::REQUIRED, 'The package to watch'],
];
}
/**
* @inheritDoc
*/
protected function getOptions()
{
return [
['npm', null, InputOption::VALUE_REQUIRED, 'Defines a custom path to the "npm" binary'],
['production', 'f', InputOption::VALUE_NONE, 'Run a "production" compilation'],
];
}
}

View File

@ -0,0 +1,73 @@
const basePath = '%base%';
const { assertSupportedNodeVersion } = require(basePath + '/node_modules/laravel-mix/src/Engine');
module.exports = async () => {
assertSupportedNodeVersion();
const mix = require(basePath + '/node_modules/laravel-mix/src/Mix').primary;
// disable manifest
mix.manifest.refresh = _ => void 0
mix.listen('init', function (mix) {
// define aliases
mix._api.alias({
'/winter': basePath,
});
// disable notifications if not in watch
%notificationInject%
// define options
mix._api.options({
processCssUrls: false,
clearConsole: false,
cssNano: {
discardComments: {removeAll: true},
}
});
// enable source maps for dev builds
if (!mix._api.inProduction()) {
mix._api.webpackConfig({
devtool: 'inline-source-map'
});
mix._api.sourceMaps();
}
});
// override default mix output
mix.listen("loading-plugins", function (plugins) {
plugins.forEach(function (plugin, index) {
if (plugin.constructor.name === "BuildOutputPlugin") {
plugins[index].apply = function (compiler) {
compiler.hooks.done.tap('BuildOutputPlugin', stats => {
if (stats.hasErrors()) {
return false;
}
if (this.options.clearConsole) {
this.clearConsole();
}
let data = stats.toJson({
assets: true,
builtAt: true,
hash: true,
performance: true,
relatedAssets: this.options.showRelated
});
if (data.assets.length) {
console.log(this.statsTable(data));
}
});
};
}
});
});
require('%mixConfigPath%');
await mix.installDependencies();
await mix.init();
return mix.build();
};

View File

@ -1,47 +0,0 @@
{
"name": "wintercms",
"description": "Free, open-source, self-hosted CMS platform based on the Laravel PHP Framework. Originally known as October CMS.",
"directories": {
"test": "tests/js/cases",
"helpers": "tests/js/helpers"
},
"scripts": {
"test": "mocha --require @babel/register tests/js/cases/**/*.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wintercms/winter.git"
},
"contributors": [
{
"name": "Alexey Bobkov",
"email": "aleksey.bobkov@gmail.com"
},
{
"name": "Samuel Georges",
"email": "daftspunky@gmail.com"
},
{
"name": "Luke Towers",
"email": "wintercms@luketowers.ca"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/wintercms/winter/issues"
},
"homepage": "https://wintercms.com/",
"devDependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/node": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/register": "^7.5.5",
"babel-plugin-module-resolver": "^3.2.0",
"chai": "^4.2.0",
"jquery": "^3.4.1",
"jsdom": "^15.1.1",
"mocha": "^6.2.0",
"sinon": "^7.4.1"
}
}

View File

@ -1,7 +1,8 @@
<?php namespace Winter\Demo\Components;
use Cms\Classes\ComponentBase;
use Flash;
use ApplicationException;
use Cms\Classes\ComponentBase;
class Todo extends ComponentBase
{
@ -36,10 +37,14 @@ class Todo extends ComponentBase
throw new ApplicationException(sprintf('Sorry only %s items are allowed.', $this->property('max')));
}
if (($newItem = post('newItem')) != '') {
$items[] = $newItem;
$newItem = post('newItem');
if (empty($newItem)) {
Flash::error('You must specify an item to add to the To Do List.');
return;
}
$items[] = $newItem;
$this->page['items'] = $items;
}
}

32
tests/js/README.md Normal file
View File

@ -0,0 +1,32 @@
# Snowboard Framework Testing Suite
The files in this directory form the testing suite for the new Winter JavaScript framework - Snowboard. You must install
all Node dependencies in order to run the testing suite:
```bash
cd tests/js
npm install
```
You can then run the tests by using the following command:
```bash
npm run test
```
Please note that the tests are run against the "built" versions of Snowboard and its plugins, in order to test
the exact same functionality that would be delivered to the end user. We do this by leveraging the `JSDOM` library to
simulate an entire HTML document.
Therefore, you must compile a new build if you make any changes and wish to run the tests:
```bash
php artisan mix:compile --package snowboard
```
You can also watch the framework for any changes, which will automatically run a build after a change is made. This will
make development and testing in parallel much quicker:
```bash
php artisan mix:watch snowboard
```

View File

@ -0,0 +1,584 @@
import FakeDom from '../../../helpers/FakeDom';
jest.setTimeout(2000);
describe('Request AJAX library', function () {
it('can be setup via a global event', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new dom.window.Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to global event
dom.window.Snowboard.addPlugin('testListener', class TestListener extends dom.window.Snowboard.Singleton {
listens() {
return {
ajaxSetup: 'ajaxSetup',
};
}
ajaxSetup(instance) {
instance.handler = 'onChanged';
}
});
dom.window.Snowboard.request(undefined, 'onTest', {
complete: (data, instance) => {
expect(instance.handler).toEqual('onChanged');
done();
}
});
}
);
});
it('can be cancelled on setup via a global event', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new dom.window.Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to global event
dom.window.Snowboard.addPlugin('testListener', class TestListener extends dom.window.Snowboard.Singleton {
listens() {
return {
ajaxSetup: 'ajaxSetup',
};
}
ajaxSetup() {
// Should cancel
return false;
}
});
const instance = dom.window.Snowboard.request(undefined, 'onTest', {
complete: (data, instance) => {
done(new Error('Request did not cancel'));
}
});
expect(instance.cancelled).toEqual(true);
done();
}
);
});
it('can be setup via a listener on the HTML element', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render('<button id="testElement">Test</button>')
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new dom.window.Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to HTML element event
const element = dom.window.document.getElementById('testElement');
element.addEventListener('ajaxSetup', (event) => {
expect(event.request).toBeDefined();
event.request.handler = 'onChanged';
});
dom.window.Snowboard.request(element, 'onTest', {
complete: (data, instance) => {
expect(instance.handler).toEqual('onChanged');
done();
}
});
}
);
});
it('can be cancelled on setup via a listener on the HTML element', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render('<button id="testElement">Test</button>')
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new dom.window.Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to HTML element event
const element = dom.window.document.getElementById('testElement');
element.addEventListener('ajaxSetup', (event) => {
event.preventDefault();
});
const instance = dom.window.Snowboard.request(element, 'onTest', {
complete: (data, instance) => {
done(new Error('Request did not cancel'));
}
});
expect(instance.cancelled).toEqual(true);
done();
}
);
});
it('can do a request and listen for completion', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
dom.window.Snowboard.request(undefined, 'onTest', {
complete: (data, instance) => {
expect(data).toEqual({
success: true,
});
expect(instance.responseData).toEqual({
success: true,
});
expect(instance.responseError).toEqual(null);
done();
return false;
}
});
}
);
});
it('can do a request and listen for completion via a global event', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to global event
dom.window.Snowboard.addPlugin('testListener', class TestListener extends dom.window.Snowboard.Singleton {
listens() {
return {
ajaxDone: 'ajaxDone',
};
}
ajaxDone(data, instance) {
expect(data).toEqual({
success: true,
});
expect(instance.responseData).toEqual({
success: true,
});
expect(instance.responseError).toEqual(null);
done();
}
});
dom.window.Snowboard.request(undefined, 'onTest');
}
);
});
it('can do a request and listen for completion via a listener on the HTML element', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render('<button id="testElement">Test</button>')
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new dom.window.Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to HTML element event
const element = dom.window.document.getElementById('testElement');
element.addEventListener('ajaxAlways', (event) => {
expect(event.request).toBeDefined();
expect(event.responseData).toEqual({
success: true
});
expect(event.responseError).toEqual(null);
done();
});
dom.window.Snowboard.request(element, 'onTest');
}
);
});
it('can do a request and listen for success', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
dom.window.Snowboard.request(undefined, 'onTest', {
success: (data, instance) => {
expect(data).toEqual({
success: true,
});
expect(instance.responseData).toEqual({
success: true,
});
expect(instance.responseError).toEqual(null);
done();
return false;
}
});
}
);
});
it('can do a request and listen for success via a global event', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate success response
const resolved = Promise.resolve({
success: true
});
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to global event
dom.window.Snowboard.addPlugin('testListener', class TestListener extends dom.window.Snowboard.Singleton {
listens() {
return {
ajaxSuccess: 'ajaxSuccess',
};
}
ajaxSuccess(data, instance) {
expect(data).toEqual({
success: true,
})
expect(instance.responseData).toEqual({
success: true,
});
expect(instance.responseError).toEqual(null);
done();
}
});
dom.window.Snowboard.request(undefined, 'onTest');
}
);
});
it('can do a request and listen for failure', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate error response
const resolved = Promise.reject('This is an error');
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
dom.window.Snowboard.request(undefined, 'onTest', {
error: (data, instance) => {
expect(data).toEqual('This is an error');
expect(instance.responseData).toEqual(null);
expect(instance.responseError).toEqual('This is an error');
done();
return false;
}
});
}
);
});
it('can do a request and listen for failure via global event', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('request').mock('doAjax', (instance) => {
// Simulate error response
const resolved = Promise.reject('This is an error');
// Mock events
instance.snowboard.globalEvent('ajaxStart', instance, resolved);
if (instance.element) {
const event = new Event('ajaxPromise');
event.promise = resolved;
instance.element.dispatchEvent(event);
}
return resolved;
});
// Listen to global event
dom.window.Snowboard.addPlugin('testListener', class TestListener extends dom.window.Snowboard.Singleton {
listens() {
return {
ajaxError: 'ajaxError',
};
}
ajaxError(data, instance) {
expect(data).toEqual('This is an error')
expect(instance.responseData).toEqual(null);
expect(instance.responseError).toEqual('This is an error');
done();
}
});
dom.window.Snowboard.request(undefined, 'onTest');
}
);
});
it('requires a valid HTML element if provided', function () {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
expect(() => {
const docFragment = dom.window.document.createDocumentFragment();
dom.window.Snowboard.request(docFragment, 'onTest');
}).toThrow('The element provided must be an Element instance');
}
);
});
it('requires a handler to be specified', function () {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
expect(() => {
dom.window.Snowboard.request(undefined, undefined);
}).toThrow('The AJAX handler name is not specified');
}
);
});
it('requires a handler to be of the correct format', function () {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'modules/system/assets/js/snowboard/build/snowboard.request.js'
])
.render()
.then(
(dom) => {
expect(() => {
dom.window.Snowboard.request(undefined, 'notRight');
}).toThrow('Invalid AJAX handler name');
}
);
});
});

View File

@ -0,0 +1,33 @@
import FakeDom from '../../../helpers/FakeDom';
describe('PluginLoader class', function () {
it('can mock plugin methods', function (done) {
FakeDom
.new()
.addScript('modules/system/assets/js/snowboard/build/snowboard.base.js')
.render()
.then(
(dom) => {
dom.window.Snowboard.getPlugin('sanitizer').mock('sanitize', () => {
return 'all good';
});
expect(
dom.window.Snowboard.sanitizer().sanitize('<p onload="derp;"></p>')
).toEqual('all good');
// Test unmock
dom.window.Snowboard.getPlugin('sanitizer').unmock('sanitize');
expect(
dom.window.Snowboard.sanitizer().sanitize('<p onload="derp;"></p>')
).toEqual('<p></p>');
done();
},
(error) => {
done(error);
}
);
});
});

View File

@ -0,0 +1,262 @@
import FakeDom from '../../../helpers/FakeDom';
describe('Snowboard framework', function () {
it('initialises correctly', function (done) {
FakeDom
.new()
.addScript('modules/system/assets/js/snowboard/build/snowboard.base.js')
.render()
.then(
(dom) => {
// Run assertions
try {
expect(dom.window.Snowboard).toBeDefined();
expect(dom.window.Snowboard.addPlugin).toBeDefined();
expect(dom.window.Snowboard.addPlugin).toEqual(expect.any(Function));
// Check PluginBase and Singleton abstracts exist
expect(dom.window.Snowboard.PluginBase).toBeDefined();
expect(dom.window.Snowboard.Singleton).toBeDefined();
// Check in-built plugins
expect(dom.window.Snowboard.getPluginNames()).toEqual(
expect.arrayContaining(['debounce', 'jsonparser', 'sanitizer'])
);
expect(dom.window.Snowboard.getPlugin('debounce').isFunction()).toEqual(true);
expect(dom.window.Snowboard.getPlugin('debounce').isSingleton()).toEqual(false);
expect(dom.window.Snowboard.getPlugin('jsonparser').isFunction()).toEqual(false);
expect(dom.window.Snowboard.getPlugin('jsonparser').isSingleton()).toEqual(true);
expect(dom.window.Snowboard.getPlugin('sanitizer').isFunction()).toEqual(false);
expect(dom.window.Snowboard.getPlugin('sanitizer').isSingleton()).toEqual(true);
done();
} catch (error) {
done(error);
}
},
(error) => {
throw error;
}
);
});
it('can add and remove a plugin', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'tests/js/fixtures/framework/TestPlugin.js',
])
.render()
.then(
(dom) => {
// Run assertions
const Snowboard = dom.window.Snowboard;
try {
// Check plugin caller
expect(Snowboard.hasPlugin('test')).toBe(true);
expect(Snowboard.getPluginNames()).toEqual(
expect.arrayContaining(['debounce', 'jsonparser', 'sanitizer', 'test'])
);
expect(Snowboard.test).toEqual(expect.any(Function));
const instance = Snowboard.test();
// Check plugin injected methods
expect(instance.snowboard).toBe(Snowboard);
expect(instance.destructor).toEqual(expect.any(Function));
// Check plugin method
expect(instance.testMethod).toBeDefined();
expect(instance.testMethod).toEqual(expect.any(Function));
expect(instance.testMethod()).toEqual('Tested');
// Check multiple instances
const instanceOne = Snowboard.test();
instanceOne.changed = true;
const instanceTwo = Snowboard.test();
expect(instanceOne).not.toEqual(instanceTwo);
const factory = Snowboard.getPlugin('test');
expect(factory.getInstances()).toEqual([instance, instanceOne, instanceTwo]);
// Remove plugin
Snowboard.removePlugin('test');
expect(Snowboard.hasPlugin('test')).toEqual(false);
expect(dom.window.Snowboard.getPluginNames()).toEqual(
expect.arrayContaining(['debounce', 'jsonparser', 'sanitizer'])
);
expect(Snowboard.test).not.toBeDefined();
done();
} catch (error) {
done(error);
}
},
(error) => {
throw error;
}
);
});
it('can add and remove a singleton', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'tests/js/fixtures/framework/TestSingleton.js',
])
.render()
.then(
(dom) => {
// Run assertions
const Snowboard = dom.window.Snowboard;
try {
// Check plugin caller
expect(Snowboard.hasPlugin('test')).toBe(true);
expect(Snowboard.getPluginNames()).toEqual(
expect.arrayContaining(['debounce', 'jsonparser', 'sanitizer', 'test'])
);
expect(Snowboard.test).toEqual(expect.any(Function));
const instance = Snowboard.test();
// Check plugin injected methods
expect(instance.snowboard).toBe(Snowboard);
expect(instance.destructor).toEqual(expect.any(Function));
// Check plugin method
expect(instance.testMethod).toBeDefined();
expect(instance.testMethod).toEqual(expect.any(Function));
expect(instance.testMethod()).toEqual('Tested');
// Check multiple instances (these should all be the same as this instance is a singleton)
const instanceOne = Snowboard.test();
instanceOne.changed = true;
const instanceTwo = Snowboard.test();
expect(instanceOne).toEqual(instanceTwo);
const factory = Snowboard.getPlugin('test');
expect(factory.getInstances()).toEqual([instance]);
// Remove plugin
Snowboard.removePlugin('test');
expect(Snowboard.hasPlugin('test')).toEqual(false);
expect(dom.window.Snowboard.getPluginNames()).toEqual(
expect.arrayContaining(['debounce', 'jsonparser', 'sanitizer'])
);
expect(Snowboard.test).not.toBeDefined();
done();
} catch (error) {
done(error);
}
},
(error) => {
throw error;
}
);
});
it('can listen and call global events', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'tests/js/fixtures/framework/TestListener.js',
])
.render()
.then(
(dom) => {
// Run assertions
const Snowboard = dom.window.Snowboard;
try {
expect(Snowboard.listensToEvent('eventOne')).toEqual(['test']);
expect(Snowboard.listensToEvent('eventTwo')).toEqual(['test']);
expect(Snowboard.listensToEvent('eventThree')).toEqual([]);
// Call global event one
const testClass = Snowboard.test();
Snowboard.globalEvent('eventOne', 42);
expect(testClass.eventResult).toEqual('Event called with arg 42');
// Call global event two - should fail as the test plugin doesn't have that method
expect(() => {
Snowboard.globalEvent('eventTwo');
}).toThrow('Missing "notExists" method in "test" plugin');
// Call global event three - nothing should happen
expect(() => {
Snowboard.globalEvent('eventThree');
}).not.toThrow();
done();
} catch (error) {
done(error);
}
},
(error) => {
throw error;
}
);
});
it('can listen and call global promise events', function (done) {
FakeDom
.new()
.addScript([
'modules/system/assets/js/snowboard/build/snowboard.base.js',
'tests/js/fixtures/framework/TestPromiseListener.js',
])
.render()
.then(
(dom) => {
// Run assertions
const Snowboard = dom.window.Snowboard;
try {
expect(Snowboard.listensToEvent('promiseOne')).toEqual(['test']);
expect(Snowboard.listensToEvent('promiseTwo')).toEqual(['test']);
expect(Snowboard.listensToEvent('promiseThree')).toEqual([]);
// Call global event one
const testClass = Snowboard.test();
Snowboard.globalPromiseEvent('promiseOne', 'promise').then(
() => {
expect(testClass.eventResult).toEqual('Event called with arg promise');
// Call global event two - it should still work, even though it doesn't return a promise
Snowboard.globalPromiseEvent('promiseTwo', 'promise 2').then(
() => {
expect(testClass.eventResult).toEqual('Promise two called with arg promise 2');
// Call global event three - it should still work
Snowboard.globalPromiseEvent('promiseThree', 'promise 3').then(
() => {
done();
},
(error) => {
done(error);
}
);
},
(error) => {
done(error);
}
);
},
(error) => {
done(error);
}
);
} catch (error) {
done(error);
}
},
(error) => {
throw error;
}
);
});
});

View File

@ -1,673 +0,0 @@
import { assert } from 'chai'
import fakeDom from 'helpers/fakeDom'
import sinon from 'sinon'
describe('modules/system/assets/js/framework.js', function () {
describe('ajaxRequests through JS', function () {
let dom,
window,
xhr,
requests = []
this.timeout(1000)
beforeEach(() => {
// Load framework.js in the fake DOM
dom = fakeDom(
'<div id="partialId" class="partialClass">Initial content</div>' +
'<script src="file://./node_modules/jquery/dist/jquery.js" id="jqueryScript"></script>' +
'<script src="file://./modules/system/assets/js/framework.js" id="frameworkScript"></script>',
{
beforeParse: (window) => {
// Mock XHR for tests below
xhr = sinon.useFakeXMLHttpRequest()
xhr.onCreate = (request) => {
requests.push(request)
}
window.XMLHttpRequest = xhr
// Allow window.location.assign() to be stubbed
delete window.location
window.location = {
href: 'https://winter.example.org/',
assign: sinon.stub()
}
}
}
)
window = dom.window
// Enable CORS on jQuery
window.jqueryScript.onload = () => {
window.jQuery.support.cors = true
}
})
afterEach(() => {
// Close window and restore XHR functionality to default
window.XMLHttpRequest = sinon.xhr.XMLHttpRequest
window.close()
requests = []
})
it('can make a successful AJAX request', function (done) {
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
success: function () {
done()
},
error: function () {
done(new Error('AJAX call failed'))
}
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
}
})
it('can make an unsuccessful AJAX request', function (done) {
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
success: function () {
done(new Error('AJAX call succeeded'))
},
error: function () {
done()
}
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a 404 Not Found response from the server
requests[1].respond(
404,
{
'Content-Type': 'text/html'
},
''
)
}
})
it('can update a partial via an ID selector', function (done) {
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
complete: function () {
let partialContent = dom.window.document.getElementById('partialId').textContent
try {
assert(
partialContent === 'Content passed through AJAX',
'Partial content incorrect - ' +
'expected "Content passed through AJAX", ' +
'found "' + partialContent + '"'
)
done()
} catch (e) {
done(e)
}
}
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a response from the server that includes a partial change via ID
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'#partialId': 'Content passed through AJAX'
})
)
}
})
it('can update a partial via a class selector', function (done) {
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
complete: function () {
let partialContent = dom.window.document.getElementById('partialId').textContent
try {
assert(
partialContent === 'Content passed through AJAX',
'Partial content incorrect - ' +
'expected "Content passed through AJAX", ' +
'found "' + partialContent + '"'
)
done()
} catch (e) {
done(e)
}
}
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a response from the server that includes a partial change via a class
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'.partialClass': 'Content passed through AJAX'
})
)
}
})
it('can redirect after a successful AJAX request', function (done) {
this.timeout(1000)
// Detect a redirect
window.location.assign.callsFake((url) => {
try {
assert(
url === '/test/success',
'Non-matching redirect URL'
)
done()
} catch (e) {
done(e)
}
})
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
redirect: '/test/success',
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
}
})
it('can send extra data with the AJAX request', function (done) {
this.timeout(1000)
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
data: {
test1: 'First',
test2: 'Second'
},
success: function () {
done()
}
})
try {
assert(
requests[1].requestBody === 'test1=First&test2=Second',
'Data incorrect or not included in request'
)
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
}
})
it('can call a beforeUpdate handler', function (done) {
const beforeUpdate = function (data, status, jqXHR) {
}
const beforeUpdateSpy = sinon.spy(beforeUpdate)
window.frameworkScript.onload = () => {
window.$.request('test::onTest', {
beforeUpdate: beforeUpdateSpy
})
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
try {
assert(
beforeUpdateSpy.withArgs(
{
'successful': true
},
'success'
).calledOnce
)
done()
} catch (e) {
done(e)
}
}
})
})
describe('ajaxRequests through HTML attributes', function () {
let dom,
window,
xhr,
requests = []
this.timeout(1000)
beforeEach(() => {
// Load framework.js in the fake DOM
dom = fakeDom(
'<a ' +
'id="standard" ' +
'href="javascript:;" ' +
'data-request="test::onTest" ' +
'data-request-success="test(\'success\')" ' +
'data-request-error="test(\'failure\')"' +
'></a>' +
'<a ' +
'id="redirect" ' +
'href="javascript:;" ' +
'data-request="test::onTest" ' +
'data-request-redirect="/test/success"' +
'></a>' +
'<a ' +
'id="dataLink" ' +
'href="javascript:;" ' +
'data-request="test::onTest" ' +
'data-request-data="test1: \'First\', test2: \'Second\'" ' +
'data-request-success="test(\'success\')" ' +
'data-request-before-update="beforeUpdateSpy($el.get(), data, textStatus)"' +
'></a>' +
'<div id="partialId" class="partialClass">Initial content</div>' +
'<script src="file://./node_modules/jquery/dist/jquery.js" id="jqueryScript"></script>' +
'<script src="file://./modules/system/assets/js/framework.js" id="frameworkScript"></script>',
{
beforeParse: (window) => {
// Mock XHR for tests below
xhr = sinon.useFakeXMLHttpRequest()
xhr.onCreate = (request) => {
requests.push(request)
}
window.XMLHttpRequest = xhr
// Add a stub for the request handlers
window.test = sinon.stub()
// Add a spy for the beforeUpdate handler
window.beforeUpdate = function (element, data, status) {
}
window.beforeUpdateSpy = sinon.spy(window.beforeUpdate)
// Stub out window.alert
window.alert = sinon.stub()
// Allow window.location.assign() to be stubbed
delete window.location
window.location = {
href: 'https://winter.example.org/',
assign: sinon.stub()
}
}
}
)
window = dom.window
// Enable CORS on jQuery
window.jqueryScript.onload = () => {
window.jQuery.support.cors = true
}
})
afterEach(() => {
// Close window and restore XHR functionality to default
window.XMLHttpRequest = sinon.xhr.XMLHttpRequest
window.close()
requests = []
})
it('can make a successful AJAX request', function (done) {
window.frameworkScript.onload = () => {
window.test.callsFake((response) => {
assert(response === 'success', 'Response handler was not "success"')
done()
})
window.$('a#standard').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
}
})
it('can make an unsuccessful AJAX request', function (done) {
window.frameworkScript.onload = () => {
window.test.callsFake((response) => {
assert(response === 'failure', 'Response handler was not "failure"')
done()
})
window.$('a#standard').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a 404 Not Found response from the server
requests[1].respond(
404,
{
'Content-Type': 'text/html'
},
''
)
}
})
it('can update a partial via an ID selector', function (done) {
window.frameworkScript.onload = () => {
window.test.callsFake(() => {
let partialContent = dom.window.document.getElementById('partialId').textContent
try {
assert(
partialContent === 'Content passed through AJAX',
'Partial content incorrect - ' +
'expected "Content passed through AJAX", ' +
'found "' + partialContent + '"'
)
done()
} catch (e) {
done(e)
}
})
window.$('a#standard').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a response from the server that includes a partial change via ID
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'#partialId': 'Content passed through AJAX'
})
)
}
})
it('can update a partial via a class selector', function (done) {
window.frameworkScript.onload = () => {
window.test.callsFake(() => {
let partialContent = dom.window.document.getElementById('partialId').textContent
try {
assert(
partialContent === 'Content passed through AJAX',
'Partial content incorrect - ' +
'expected "Content passed through AJAX", ' +
'found "' + partialContent + '"'
)
done()
} catch (e) {
done(e)
}
})
window.$('a#standard').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a response from the server that includes a partial change via a class
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'.partialClass': 'Content passed through AJAX'
})
)
}
})
it('can redirect after a successful AJAX request', function (done) {
this.timeout(1000)
// Detect a redirect
window.location.assign.callsFake((url) => {
try {
assert(
url === '/test/success',
'Non-matching redirect URL'
)
done()
} catch (e) {
done(e)
}
})
window.frameworkScript.onload = () => {
window.$('a#redirect').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'succesful': true
})
)
}
})
it('can send extra data with the AJAX request', function (done) {
this.timeout(1000)
window.frameworkScript.onload = () => {
window.test.callsFake((response) => {
assert(response === 'success', 'Response handler was not "success"')
done()
})
window.$('a#dataLink').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'succesful': true
})
)
}
})
it('can call a beforeUpdate handler', function (done) {
this.timeout(1000)
window.frameworkScript.onload = () => {
window.test.callsFake((response) => {
assert(response === 'success', 'Response handler was not "success"')
})
window.$('a#dataLink').click()
try {
assert(
requests[1].requestHeaders['X-WINTER-REQUEST-HANDLER'] === 'test::onTest',
'Incorrect Winter request handler'
)
} catch (e) {
done(e)
}
// Mock a successful response from the server
requests[1].respond(
200,
{
'Content-Type': 'application/json'
},
JSON.stringify({
'successful': true
})
)
try {
assert(
window.beforeUpdateSpy.withArgs(
window.$('a#dataLink').get(),
{
'successful': true
},
'success'
).calledOnce,
'beforeUpdate handler never called, or incorrect arguments provided'
)
done()
} catch (e) {
done(e)
}
}
})
})
})

View File

@ -1,111 +0,0 @@
import { assert } from 'chai'
import fakeDom from 'helpers/fakeDom'
describe('modules/system/assets/ui/js/select.js', function () {
describe('AJAX processResults function', function () {
let dom,
window,
processResults,
keyValResultFormat = {
value1: 'text1',
value2: 'text2'
},
select2ResultFormat = [
{
id: 'value1',
text: 'text1',
disabled: true
},
{
id: 'value2',
text: 'text2',
selected: false
}
]
this.timeout(1000)
beforeEach((done) => {
// Load framework.js and select.js in the fake DOM
dom = fakeDom(`
<select class="custom-select" data-handler="onSearch"></select>
<script src="file://./node_modules/jquery/dist/jquery.js" id="jqueryScript"></script>
<script src="file://./modules/system/assets/js/framework.js" id="frameworkScript"></script>
<script src="file://./modules/system/assets/ui/js/select.js" id="selectScript"></script>
`)
window = dom.window
window.selectScript.onload = () => {
window.jQuery.fn.select2 = function(options) {
processResults = options.ajax.processResults
done()
}
}
})
afterEach(() => {
window.close()
})
it('supports a key-value mapping on the "result" key', function () {
let result = processResults({ result: keyValResultFormat })
assert.deepEqual(result, { results: [
{
id: 'value1',
text: 'text1'
},
{
id: 'value2',
text: 'text2'
}
]})
})
it('supports a key-value mapping on the "results" key', function() {
let result = processResults({ results: keyValResultFormat })
assert.deepEqual(result, { results: [
{
id: 'value1',
text: 'text1'
},
{
id: 'value2',
text: 'text2'
}
]})
})
it('passes through other data provided with key-value mapping', function() {
let result = processResults({ result: keyValResultFormat, other1: 1, other2: '2' })
assert.include(result, { other1: 1, other2: '2'})
})
it('supports the Select2 result format on the "result" key', function() {
let result = processResults({ result: select2ResultFormat })
assert.deepEqual(result, { results: select2ResultFormat })
})
it('passes through the Select2 result format on the "results" key', function() {
let result = processResults({ results: select2ResultFormat })
assert.deepEqual(result, { results: select2ResultFormat })
})
it('passes through other data provided with Select2 results format', function() {
let result = processResults({ results: select2ResultFormat, pagination: { more: true }, other: 'value' })
assert.deepInclude(result, { pagination: { more: true }, other: 'value' })
})
it('passes through the Select2 format with a group as the first entry', function() {
let data = [
{
text: 'Label',
children: select2ResultFormat
}
]
let result = processResults({ results: data })
assert.deepEqual(result, { results: data })
})
})
})

View File

@ -0,0 +1,18 @@
/* globals window */
((Snowboard) => {
class TestListener extends Snowboard.Singleton {
listens() {
return {
eventOne: 'eventOne',
eventTwo: 'notExists'
};
}
eventOne(arg) {
this.eventResult = 'Event called with arg ' + arg;
}
}
Snowboard.addPlugin('test', TestListener);
})(window.Snowboard);

View File

@ -0,0 +1,11 @@
/* globals window */
((Snowboard) => {
class TestPlugin extends Snowboard.PluginBase {
testMethod() {
return 'Tested';
}
}
Snowboard.addPlugin('test', TestPlugin);
})(window.Snowboard);

View File

@ -0,0 +1,28 @@
/* globals window */
((Snowboard) => {
class TestPromiseListener extends Snowboard.Singleton {
listens() {
return {
promiseOne: 'promiseOne',
promiseTwo: 'promiseTwo'
};
}
promiseOne(arg) {
return new Promise((resolve) => {
window.setTimeout(() => {
this.eventResult = 'Event called with arg ' + arg;
resolve();
}, 500);
});
}
promiseTwo(arg) {
this.eventResult = 'Promise two called with arg ' + arg;
return true;
}
}
Snowboard.addPlugin('test', TestPromiseListener);
})(window.Snowboard);

View File

@ -0,0 +1,11 @@
/* globals window */
((Snowboard) => {
class TestSingleton extends Snowboard.Singleton {
testMethod() {
return 'Tested';
}
}
Snowboard.addPlugin('test', TestSingleton);
})(window.Snowboard);

View File

@ -1,40 +1,156 @@
/* globals __dirname, URL */
import { JSDOM } from 'jsdom'
import path from 'path'
const defaults = {
url: 'https://winter.example.org/',
referer: null,
contentType: 'text/html',
head: '<!DOCTYPE html><html><head><title>Fake document</title></head>',
bodyStart: '<body>',
bodyEnd: '</body>',
foot: '</html>',
beforeParse: null
}
const fakeDom = (content, options) => {
const settings = Object.assign({}, defaults, options)
const dom = new JSDOM(
settings.head +
settings.bodyStart +
(content + '') +
settings.bodyEnd +
settings.foot,
{
url: settings.url,
referrer: settings.referer || undefined,
contentType: settings.contenType,
includeNodeLocations: true,
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
beforeParse: (typeof settings.beforeParse === 'function')
? settings.beforeParse
: undefined
export default class FakeDom
{
constructor(content, options)
{
if (options === undefined) {
options = {}
}
)
return dom
// Header settings
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>'
// Callback settings
this.beforeParse = (typeof options.beforeParse === 'function')
? options.beforeParse
: undefined
// Scripts
this.scripts = []
this.inline = []
}
static new(content, options)
{
return new FakeDom(content, options)
}
setContent(content)
{
this.content = content
return this
}
addScript(script, id)
{
if (Array.isArray(script)) {
script.forEach((item) => {
this.addScript(item)
})
return this
}
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
}
addInlineScript(script, id)
{
this.inline.push({
script,
id: id || this.generateId(),
element: null,
})
return this
}
generateId()
{
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}`
}
return id
}
render(content)
{
if (content) {
this.content = content;
}
return new Promise((resolve, reject) => {
try {
const dom = new JSDOM(
this._renderContent(),
{
url: this.url,
referrer: this.referer,
contentType: this.contentType,
includeNodeLocations: true,
runScripts: 'dangerously',
resources: 'usable',
pretendToBeVisual: true,
beforeParse: this.beforeParse,
}
)
dom.window.resolver = () => {
resolve(dom)
}
} catch (e) {
reject(e)
}
})
}
_renderContent()
{
// Create content list
const content = [
this.head,
this.bodyStart,
this.content,
]
// Embed scripts
this.scripts.forEach((script) => {
content.push(`<script src="${script.url}" id="${script.id}"></script>`)
})
this.inline.forEach((script) => {
content.push(`<script id="${script.id}">${script.script}</script>`)
})
// Add resolver
content.push(`<script>window.resolver()</script>`)
// Add final content
content.push(this.bodyEnd)
content.push(this.foot)
return content.join('\n')
}
}
export default fakeDom

194
tests/js/jest.config.js Normal file
View File

@ -0,0 +1,194 @@
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/81/m6w95r0j7ms_10c47hdbz4gw0000gn/T/jest_dx",
// Automatically clear mock calls, instances and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

37
tests/js/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "@wintercms/tests",
"description": "Test cases for the Winter JavaScript framework",
"private": true,
"scripts": {
"test": "jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/wintercms/winter.git"
},
"contributors": [
{
"name": "Ben Thomson",
"email": "git@alfreido.com"
},
{
"name": "Winter CMS Maintainers",
"url": "https://wintercms.com/"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/wintercms/winter/issues"
},
"homepage": "https://wintercms.com/",
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.4",
"@babel/preset-env": "^7.16.4",
"@babel/register": "^7.16.0",
"@babel/runtime": "^7.16.3",
"babel-plugin-module-resolver": "^4.1.0",
"jest": "^27.4.3",
"jsdom": "^18.1.1"
}
}

View File

@ -36,7 +36,7 @@ description = "Default layout"
<script src="{{ 'assets/vendor/jquery.js'|theme }}"></script>
<script src="{{ 'assets/vendor/bootstrap.js'|theme }}"></script>
<script src="{{ 'assets/javascript/app.js'|theme }}"></script>
{% framework extras %}
{% snowboard all %}
{% scripts %}
</body>

View File

@ -5,9 +5,14 @@ layout = "default"
<?php
function onTest()
{
$value1 = input('value1');
$value2 = input('value2');
$operation = input('operation');
$value1 = input('value1', '');
$value2 = input('value2', '');
$operation = input('operation', '');
if (!is_numeric($value1) || $value1 === '' || !is_numeric($value2) || $value2 === '') {
$this['result'] = null;
return;
}
switch ($operation) {
case '+' :
@ -45,14 +50,14 @@ function onTest()
</div>
<div class="panel-body">
<form role="form" class="form-inline" data-request="onTest" data-request-update="calcresult: '#result'">
<input type="text" class="form-control" value="15" name="value1" style="width:100px">
<select class="form-control" name="operation" style="width: 70px">
<input type="text" class="form-control" value="15" name="value1" style="width:100px" data-track-input>
<select class="form-control" name="operation" style="width: 70px" data-track-input>
<option>+</option>
<option>-</option>
<option>*</option>
<option>/</option>
</select>
<input type="text" class="form-control" value="5" name="value2" style="width:100px">
<input type="text" class="form-control" value="5" name="value2" style="width:100px" data-track-input>
<button type="submit" class="btn btn btn-primary" data-attach-loading>Calculate</button>
</form>
</div>