mirror of
https://github.com/wintercms/winter.git
synced 2024-06-28 05:33:29 +02:00
Compile changes from wip/framework-rewrite branch
Represents the entire current state of the Snowboard framework.
This commit is contained in:
parent
6bbfd0e885
commit
ae67e2fa9b
13
.babelrc
13
.babelrc
@ -1,13 +0,0 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": [
|
||||
[
|
||||
"module-resolver", {
|
||||
"root": ["."],
|
||||
"alias": {
|
||||
"helpers": "./tests/js/helpers"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
21
.github/workflows/code-quality.yaml
vendored
21
.github/workflows/code-quality.yaml
vendored
@ -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 .
|
||||
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@ -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:
|
||||
|
65
modules/cms/twig/SnowboardNode.php
Normal file
65
modules/cms/twig/SnowboardNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
60
modules/cms/twig/SnowboardTokenParser.php
Normal file
60
modules/cms/twig/SnowboardTokenParser.php
Normal 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';
|
||||
}
|
||||
}
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
0
modules/system/assets/css/snowboard.extras.css
Normal file
0
modules/system/assets/css/snowboard.extras.css
Normal file
2
modules/system/assets/js/snowboard/.eslintignore
Normal file
2
modules/system/assets/js/snowboard/.eslintignore
Normal file
@ -0,0 +1,2 @@
|
||||
**/node_modules/**
|
||||
build/*.js
|
22
modules/system/assets/js/snowboard/.eslintrc.json
Normal file
22
modules/system/assets/js/snowboard/.eslintrc.json
Normal 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
|
||||
}]
|
||||
}
|
||||
}
|
3
modules/system/assets/js/snowboard/.gitignore
vendored
Normal file
3
modules/system/assets/js/snowboard/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Ignore packages
|
||||
node_modules
|
||||
package-lock.json
|
48
modules/system/assets/js/snowboard/abstracts/PluginBase.js
Normal file
48
modules/system/assets/js/snowboard/abstracts/PluginBase.js
Normal 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;
|
||||
}
|
||||
}
|
15
modules/system/assets/js/snowboard/abstracts/Singleton.js
Normal file
15
modules/system/assets/js/snowboard/abstracts/Singleton.js
Normal 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 {
|
||||
}
|
789
modules/system/assets/js/snowboard/ajax/Request.js
Normal file
789
modules/system/assets/js/snowboard/ajax/Request.js
Normal 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);
|
@ -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
@ -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
68
modules/system/assets/js/snowboard/extras/AttachLoading.js
Normal file
68
modules/system/assets/js/snowboard/extras/AttachLoading.js
Normal 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';
|
||||
}
|
||||
}
|
129
modules/system/assets/js/snowboard/extras/Flash.js
Normal file
129
modules/system/assets/js/snowboard/extras/Flash.js
Normal 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);
|
||||
}
|
||||
}
|
68
modules/system/assets/js/snowboard/extras/FlashListener.js
Normal file
68
modules/system/assets/js/snowboard/extras/FlashListener.js
Normal 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;
|
||||
}
|
||||
}
|
88
modules/system/assets/js/snowboard/extras/StripeLoader.js
Normal file
88
modules/system/assets/js/snowboard/extras/StripeLoader.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
172
modules/system/assets/js/snowboard/extras/Transition.js
Normal file
172
modules/system/assets/js/snowboard/extras/Transition.js
Normal 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`;
|
||||
}
|
||||
}
|
258
modules/system/assets/js/snowboard/main/PluginLoader.js
Normal file
258
modules/system/assets/js/snowboard/main/PluginLoader.js
Normal 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];
|
||||
}
|
||||
}
|
342
modules/system/assets/js/snowboard/main/Snowboard.js
Normal file
342
modules/system/assets/js/snowboard/main/Snowboard.js
Normal 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 */
|
||||
}
|
||||
}
|
37
modules/system/assets/js/snowboard/package.json
Normal file
37
modules/system/assets/js/snowboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
10
modules/system/assets/js/snowboard/snowboard.base.debug.js
Normal file
10
modules/system/assets/js/snowboard/snowboard.base.debug.js
Normal 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);
|
10
modules/system/assets/js/snowboard/snowboard.base.js
Normal file
10
modules/system/assets/js/snowboard/snowboard.base.js
Normal 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);
|
15
modules/system/assets/js/snowboard/snowboard.extras.js
Normal file
15
modules/system/assets/js/snowboard/snowboard.extras.js
Normal 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);
|
18
modules/system/assets/js/snowboard/utilities/Debounce.js
Normal file
18
modules/system/assets/js/snowboard/utilities/Debounce.js
Normal 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);
|
||||
});
|
||||
};
|
||||
};
|
387
modules/system/assets/js/snowboard/utilities/JsonParser.js
Normal file
387
modules/system/assets/js/snowboard/utilities/JsonParser.js
Normal 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';
|
||||
}
|
||||
}
|
58
modules/system/assets/js/snowboard/utilities/Sanitizer.js
Normal file
58
modules/system/assets/js/snowboard/utilities/Sanitizer.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
modules/system/assets/js/snowboard/winter-mix.js
Normal file
17
modules/system/assets/js/snowboard/winter-mix.js
Normal 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',
|
||||
});
|
259
modules/system/assets/less/snowboard.extras.less
Normal file
259
modules/system/assets/less/snowboard.extras.less
Normal 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);
|
||||
}
|
185
modules/system/classes/MixAssets.php
Normal file
185
modules/system/classes/MixAssets.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
139
modules/system/console/MixCompile.php
Normal file
139
modules/system/console/MixCompile.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
101
modules/system/console/MixInstall.php
Normal file
101
modules/system/console/MixInstall.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
46
modules/system/console/MixList.php
Normal file
46
modules/system/console/MixList.php
Normal 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;
|
||||
}
|
||||
}
|
95
modules/system/console/MixWatch.php
Normal file
95
modules/system/console/MixWatch.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
73
modules/system/console/fixtures/mix.webpack.js.fixture
Normal file
73
modules/system/console/fixtures/mix.webpack.js.fixture
Normal 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();
|
||||
};
|
47
package.json
47
package.json
@ -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"
|
||||
}
|
||||
}
|
@ -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
32
tests/js/README.md
Normal 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
|
||||
```
|
584
tests/js/cases/snowboard/ajax/Request.test.js
Normal file
584
tests/js/cases/snowboard/ajax/Request.test.js
Normal 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');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
33
tests/js/cases/snowboard/main/PluginLoader.test.js
Normal file
33
tests/js/cases/snowboard/main/PluginLoader.test.js
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
262
tests/js/cases/snowboard/main/Snowboard.test.js
Normal file
262
tests/js/cases/snowboard/main/Snowboard.test.js
Normal 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;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
18
tests/js/fixtures/framework/TestListener.js
Normal file
18
tests/js/fixtures/framework/TestListener.js
Normal 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);
|
11
tests/js/fixtures/framework/TestPlugin.js
Normal file
11
tests/js/fixtures/framework/TestPlugin.js
Normal file
@ -0,0 +1,11 @@
|
||||
/* globals window */
|
||||
|
||||
((Snowboard) => {
|
||||
class TestPlugin extends Snowboard.PluginBase {
|
||||
testMethod() {
|
||||
return 'Tested';
|
||||
}
|
||||
}
|
||||
|
||||
Snowboard.addPlugin('test', TestPlugin);
|
||||
})(window.Snowboard);
|
28
tests/js/fixtures/framework/TestPromiseListener.js
Normal file
28
tests/js/fixtures/framework/TestPromiseListener.js
Normal 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);
|
11
tests/js/fixtures/framework/TestSingleton.js
Normal file
11
tests/js/fixtures/framework/TestSingleton.js
Normal file
@ -0,0 +1,11 @@
|
||||
/* globals window */
|
||||
|
||||
((Snowboard) => {
|
||||
class TestSingleton extends Snowboard.Singleton {
|
||||
testMethod() {
|
||||
return 'Tested';
|
||||
}
|
||||
}
|
||||
|
||||
Snowboard.addPlugin('test', TestSingleton);
|
||||
})(window.Snowboard);
|
@ -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
194
tests/js/jest.config.js
Normal 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
37
tests/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user