diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml
index 834d662f78..58bfdf2a27 100644
--- a/.github/workflows/code-quality.yaml
+++ b/.github/workflows/code-quality.yaml
@@ -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 .
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 984e4171ba..ad0c7b0ad8 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -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:
diff --git a/.gitpod.yml b/.gitpod.yml
index d9d1ca589b..8bd98780ce 100644
--- a/.gitpod.yml
+++ b/.gitpod.yml
@@ -27,10 +27,10 @@ vscode:
github:
prebuilds:
- master: true
+ master: false
branches: false
- pullRequests: true
+ pullRequests: false
pullRequestsFromForks: false
- addCheck: true
+ addCheck: false
addComment: false
addBadge: true
diff --git a/.jshintrc b/.jshintrc
deleted file mode 100644
index bb55890a99..0000000000
--- a/.jshintrc
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "esversion": 6,
- "curly": true,
- "asi": true
-}
diff --git a/composer.json b/composer.json
index 4f7c896b5e..7b6061435d 100644
--- a/composer.json
+++ b/composer.json
@@ -83,5 +83,11 @@
"replace": false,
"merge-dev": false
}
+ },
+ "config": {
+ "allow-plugins": {
+ "composer/installers": true,
+ "wikimedia/composer-merge-plugin": true
+ }
}
}
diff --git a/modules/cms/twig/Extension.php b/modules/cms/twig/Extension.php
index c8e2eeb834..867087888c 100644
--- a/modules/cms/twig/Extension.php
+++ b/modules/cms/twig/Extension.php
@@ -73,6 +73,7 @@ public function getTokenParsers()
new PlaceholderTokenParser,
new DefaultTokenParser,
new FrameworkTokenParser,
+ new SnowboardTokenParser,
new ComponentTokenParser,
new FlashTokenParser,
new ScriptsTokenParser,
diff --git a/modules/cms/twig/SnowboardNode.php b/modules/cms/twig/SnowboardNode.php
new file mode 100644
index 0000000000..6a0928dea4
--- /dev/null
+++ b/modules/cms/twig/SnowboardNode.php
@@ -0,0 +1,65 @@
+ $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 ''.PHP_EOL;" . PHP_EOL);
+ static::$baseLoaded = true;
+ }
+
+ foreach ($modules as $module) {
+ $moduleJs = $moduleMap[$module];
+ $compiler
+ ->write("echo ''.PHP_EOL;" . PHP_EOL);
+ }
+ }
+}
diff --git a/modules/cms/twig/SnowboardTokenParser.php b/modules/cms/twig/SnowboardTokenParser.php
new file mode 100644
index 0000000000..2bfb4d34ad
--- /dev/null
+++ b/modules/cms/twig/SnowboardTokenParser.php
@@ -0,0 +1,60 @@
+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';
+ }
+}
diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php
index f74380826c..bffd0eca18 100644
--- a/modules/system/ServiceProvider.php
+++ b/modules/system/ServiceProvider.php
@@ -32,6 +32,7 @@
use Winter\Storm\Router\Helper as RouterHelper;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Schema;
+use System\Classes\MixAssets;
class ServiceProvider extends ModuleServiceProvider
{
@@ -251,32 +252,37 @@ protected function registerConsole()
/*
* 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('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('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('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::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::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);
}
/*
@@ -560,6 +566,11 @@ protected function registerAssetBundles()
$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/winter.mix.js');
});
}
diff --git a/modules/system/assets/css/snowboard.extras.css b/modules/system/assets/css/snowboard.extras.css
new file mode 100644
index 0000000000..edd80c9ee0
--- /dev/null
+++ b/modules/system/assets/css/snowboard.extras.css
@@ -0,0 +1,57 @@
+body.wn-loading,
+body.wn-loading *,
+body.oc-loading,
+body.oc-loading *{cursor:wait !important}
+.stripe-loading-indicator{height:5px;background:transparent;position:fixed;top:0;left:0;width:100%;overflow:hidden;z-index:2000}
+.stripe-loading-indicator .stripe,
+.stripe-loading-indicator .stripe-loaded{height:5px;display:block;background:#0090c0;position:absolute;-webkit-box-shadow:inset 0 1px 1px -1px #FFF,inset 0 -1px 1px -1px #FFF;box-shadow:inset 0 1px 1px -1px #FFF,inset 0 -1px 1px -1px #FFF}
+.stripe-loading-indicator .stripe{width:100%;-webkit-animation:wn-infinite-loader 60s linear;animation:wn-infinite-loader 60s linear}
+.stripe-loading-indicator .stripe-loaded{width:100%;transform:translate3d(-100%,0,0);opacity:0;filter:alpha(opacity=0)}
+.stripe-loading-indicator.loaded{opacity:0;filter:alpha(opacity=0);-webkit-transition:opacity 0.4s linear;transition:opacity 0.4s linear;-webkit-transition-delay:0.3s;transition-delay:0.3s}
+.stripe-loading-indicator.loaded .stripe{animation-play-state:paused}
+.stripe-loading-indicator.loaded .stripe-loaded{opacity:1;filter:alpha(opacity=100);transform:translate3d(0,0,0);-webkit-transition:transform 0.3s linear;transition:transform 0.3s linear}
+.stripe-loading-indicator.hide{display:none}
+body>div.flash-message{position:fixed;width:500px;left:50%;top:13px;margin-left:-250px;color:#fff;font-size:14px;padding:10px 30px 14px 15px;z-index:10300;word-wrap:break-word;text-shadow:0 -1px 0 rgba(0,0,0,0.15);text-align:center;-webkit-box-shadow:0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.24);box-shadow:0 1px 6px rgba(0,0,0,0.12),0 1px 4px rgba(0,0,0,0.24);-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;cursor:pointer}
+body>div.flash-message .flash-timer{position:absolute;bottom:0;left:0;width:100%;height:4px;background:#fff;opacity:0.5;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px}
+body>div.flash-message .flash-timer.timeout-active{-webkit-transition:width 6s linear;transition:width 6s linear}
+body>div.flash-message .flash-timer.timeout-in{width:100%}
+body>div.flash-message .flash-timer.timeout-out{width:0%}
+body>div.flash-message.show-active,
+body>div.flash-message.hide-active{-webkit-transition:all 0.5s,width 0s;transition:all 0.5s,width 0s}
+body>div.flash-message.show-in,
+body>div.flash-message.hide-out{opacity:0;filter:alpha(opacity=0);-webkit-transform:scale(0.9);-ms-transform:scale(0.9);transform:scale(0.9)}
+body>div.flash-message.show-out,
+body>div.flash-message.hide-in{opacity:1;filter:alpha(opacity=100);-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}
+body>div.flash-message.success{background:#8da85e}
+body>div.flash-message.error{background:#c30}
+body>div.flash-message.warning{background:#f0ad4e}
+body>div.flash-message.info{background:#5fb6f5}
+@media (max-width:768px){body>div.flash-message{left:10px;right:10px;top:10px;margin-left:0;width:auto}}
+[data-request][data-request-validate] [data-validate-for]:not(.visible),
+[data-request][data-request-validate] [data-validate-error]:not(.visible){display:none}
+a.wn-loading:after,
+button.wn-loading:after,
+span.wn-loading:after,
+a.oc-loading:after,
+button.oc-loading:after,
+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:0.5;filter:alpha(opacity=50)}
+@-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)}}
+@-moz-keyframes wn-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-webkit-keyframes wn-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-o-keyframes wn-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-ms-keyframes wn-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@keyframes wn-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-moz-keyframes oc-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-webkit-keyframes oc-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-o-keyframes oc-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@-ms-keyframes oc-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
+@keyframes oc-infinite-loader{0%{transform:translateX(-100%)}10%{transform:translateX(-50%)}20%{transform:translateX(-25%)}30%{transform:translateX(-12.5%)}40%{transform:translateX(-6.25%)}50%{transform:translateX(-3.125%)}60%{transform:translateX(-1.5625%)}70%{transform:translateX(-0.78125%)}80%{transform:translateX(-0.390625%)}90%{transform:translateX(-0.1953125%)}100%{transform:translateX(-0.09765625%)}}
\ No newline at end of file
diff --git a/modules/system/assets/js/snowboard/.eslintignore b/modules/system/assets/js/snowboard/.eslintignore
new file mode 100644
index 0000000000..089411e422
--- /dev/null
+++ b/modules/system/assets/js/snowboard/.eslintignore
@@ -0,0 +1,2 @@
+**/node_modules/**
+build/*.js
diff --git a/modules/system/assets/js/snowboard/.eslintrc.json b/modules/system/assets/js/snowboard/.eslintrc.json
new file mode 100644
index 0000000000..2924496b83
--- /dev/null
+++ b/modules/system/assets/js/snowboard/.eslintrc.json
@@ -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
+ }]
+ }
+}
diff --git a/modules/system/assets/js/snowboard/.gitignore b/modules/system/assets/js/snowboard/.gitignore
new file mode 100644
index 0000000000..4b66340796
--- /dev/null
+++ b/modules/system/assets/js/snowboard/.gitignore
@@ -0,0 +1,3 @@
+# Ignore packages
+node_modules
+package-lock.json
diff --git a/modules/system/assets/js/snowboard/abstracts/PluginBase.js b/modules/system/assets/js/snowboard/abstracts/PluginBase.js
new file mode 100644
index 0000000000..c50f2556c5
--- /dev/null
+++ b/modules/system/assets/js/snowboard/abstracts/PluginBase.js
@@ -0,0 +1,48 @@
+/**
+ * Plugin base abstract.
+ *
+ * This class provides the base functionality for all plugins.
+ *
+ * @copyright 2021 Winter.
+ * @author Ben Thomson
+ */
+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;
+ }
+}
diff --git a/modules/system/assets/js/snowboard/abstracts/Singleton.js b/modules/system/assets/js/snowboard/abstracts/Singleton.js
new file mode 100644
index 0000000000..557e09c3d8
--- /dev/null
+++ b/modules/system/assets/js/snowboard/abstracts/Singleton.js
@@ -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
+ */
+export default class Singleton extends PluginBase {
+}
diff --git a/modules/system/assets/js/snowboard/ajax/Request.js b/modules/system/assets/js/snowboard/ajax/Request.js
new file mode 100644
index 0000000000..16c90450e1
--- /dev/null
+++ b/modules/system/assets/js/snowboard/ajax/Request.js
@@ -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
+ */
+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.fetchOptions = {};
+ 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 ['cookie', 'jsonParser'];
+ }
+
+ /**
+ * 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".');
+ }
+ }
+
+ /**
+ * Creates a Fetch request.
+ *
+ * This method is made available for plugins to extend or override the default fetch() settings with their own.
+ *
+ * @returns {Promise}
+ */
+ getFetch() {
+ this.fetchOptions = (this.options.fetchOptions !== undefined && typeof this.options.fetchOptions === 'object')
+ ? this.options.fetchOptions
+ : {
+ method: 'POST',
+ headers: this.headers,
+ body: this.data,
+ redirect: 'follow',
+ mode: 'same-origin',
+ };
+
+ this.snowboard.globalEvent('ajaxFetchOptions', this.fetchOptions, this);
+
+ return fetch(this.url, this.fetchOptions);
+ }
+
+ /**
+ * 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) => {
+ this.getFetch().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) => {
+ switch (mode) {
+ case 'append':
+ element.innerHTML += content;
+ break;
+ case 'prepend':
+ element.innerHTML = content + element.innerHTML;
+ break;
+ case 'replace':
+ default:
+ element.innerHTML = content;
+ break;
+ }
+
+ affected.push(element);
+
+ // Fire update event for each element that is updated
+ this.snowboard.globalEvent('ajaxUpdate', element, content, this);
+ const event = new Event('ajaxUpdate');
+ event.content = content;
+ 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;
+ }
+ }
+
+ if (this.flash && response.X_WINTER_FLASH_MESSAGES) {
+ this.processFlashMessages(response.X_WINTER_FLASH_MESSAGES);
+ }
+
+ // Check for a redirect from the response, or use the redirect as specified in the options.
+ if (this.redirect || response.X_WINTER_REDIRECT) {
+ this.processRedirect(this.redirect || response.X_WINTER_REDIRECT);
+ return;
+ }
+
+ 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() {
+ return this.snowboard.cookie().get('XSRF-TOKEN');
+ }
+
+ 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);
diff --git a/modules/system/assets/js/snowboard/ajax/handlers/AttributeRequest.js b/modules/system/assets/js/snowboard/ajax/handlers/AttributeRequest.js
new file mode 100644
index 0000000000..61effed781
--- /dev/null
+++ b/modules/system/assets/js/snowboard/ajax/handlers/AttributeRequest.js
@@ -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
+ */
+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);
diff --git a/modules/system/assets/js/snowboard/build/snowboard.base.debug.js b/modules/system/assets/js/snowboard/build/snowboard.base.debug.js
new file mode 100644
index 0000000000..5ab4e90740
--- /dev/null
+++ b/modules/system/assets/js/snowboard/build/snowboard.base.debug.js
@@ -0,0 +1,3 @@
+!function(){var t={2220:function(t,e,n){var r=n(2569),o=n(4354),i=n(3700),s=r.TypeError;t.exports=function(t){if(o(t))return t;throw s(i(t)+" is not a function")}},3467:function(t,e,n){var r=n(2569),o=n(4354),i=r.String,s=r.TypeError;t.exports=function(t){if("object"==typeof t||o(t))return t;throw s("Can't set "+i(t)+" as a prototype")}},2834:function(t,e,n){var r=n(2931),o=n(9062),i=n(378),s=r("unscopables"),c=Array.prototype;null==c[s]&&i.f(c,s,{configurable:!0,value:o(null)}),t.exports=function(t){c[s][t]=!0}},1421:function(t,e,n){var r=n(2569),o=n(1651),i=r.String,s=r.TypeError;t.exports=function(t){if(o(t))return t;throw s(i(t)+" is not an object")}},4041:function(t,e,n){var r=n(7830),o=n(7841),i=n(6095),s=function(t){return function(e,n,s){var c,a=r(e),u=i(a),f=o(s,u);if(t&&n!=n){for(;u>f;)if((c=a[f++])!=c)return!0}else for(;u>f;f++)if((t||f in a)&&a[f]===n)return t||f||0;return!t&&-1}};t.exports={includes:s(!0),indexOf:s(!1)}},8250:function(t,e,n){var r=n(1540),o=r({}.toString),i=r("".slice);t.exports=function(t){return i(o(t),8,-1)}},8778:function(t,e,n){var r=n(5320),o=n(6830),i=n(892),s=n(378);t.exports=function(t,e,n){for(var c=o(e),a=s.f,u=i.f,f=0;f0&&r[0]<4?1:+(r[0]+r[1])),!o&&s&&(!(r=s.match(/Edge\/(\d+)/))||r[1]>=74)&&(r=s.match(/Chrome\/(\d+)/))&&(o=+r[1]),t.exports=o},4328:function(t){t.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},7641:function(t,e,n){var r=n(2569),o=n(892).f,i=n(7632),s=n(8946),c=n(2024),a=n(8778),u=n(8787);t.exports=function(t,e){var n,f,l,h,p,g=t.target,d=t.global,b=t.stat;if(n=d?r:b?r[g]||c(g,{}):(r[g]||{}).prototype)for(f in e){if(h=e[f],l=t.noTargetGet?(p=o(n,f))&&p.value:n[f],!u(d?f:g+(b?".":"#")+f,t.forced)&&void 0!==l){if(typeof h==typeof l)continue;a(h,l)}(t.sham||l&&l.sham)&&i(h,"sham",!0),s(n,f,h,t)}}},2112:function(t){t.exports=function(t){try{return!!t()}catch(t){return!0}}},9581:function(t,e,n){var r=n(2112);t.exports=!r((function(){var t=function(){}.bind();return"function"!=typeof t||t.hasOwnProperty("prototype")}))},7425:function(t,e,n){var r=n(9581),o=Function.prototype.call;t.exports=r?o.bind(o):function(){return o.apply(o,arguments)}},34:function(t,e,n){var r=n(1738),o=n(5320),i=Function.prototype,s=r&&Object.getOwnPropertyDescriptor,c=o(i,"name"),a=c&&"something"===function(){}.name,u=c&&(!r||r&&s(i,"name").configurable);t.exports={EXISTS:c,PROPER:a,CONFIGURABLE:u}},1540:function(t,e,n){var r=n(9581),o=Function.prototype,i=o.bind,s=o.call,c=r&&i.bind(s,s);t.exports=r?function(t){return t&&c(t)}:function(t){return t&&function(){return s.apply(t,arguments)}}},2430:function(t,e,n){var r=n(2569),o=n(4354),i=function(t){return o(t)?t:void 0};t.exports=function(t,e){return arguments.length<2?i(r[t]):r[t]&&r[t][e]}},5324:function(t,e,n){var r=n(2220);t.exports=function(t,e){var n=t[e];return null==n?void 0:r(n)}},2569:function(t,e,n){var r=function(t){return t&&t.Math==Math&&t};t.exports=r("object"==typeof globalThis&&globalThis)||r("object"==typeof window&&window)||r("object"==typeof self&&self)||r("object"==typeof n.g&&n.g)||function(){return this}()||Function("return this")()},5320:function(t,e,n){var r=n(1540),o=n(6416),i=r({}.hasOwnProperty);t.exports=Object.hasOwn||function(t,e){return i(o(t),e)}},9012:function(t){t.exports={}},99:function(t,e,n){var r=n(2430);t.exports=r("document","documentElement")},8232:function(t,e,n){var r=n(1738),o=n(2112),i=n(7934);t.exports=!r&&!o((function(){return 7!=Object.defineProperty(i("div"),"a",{get:function(){return 7}}).a}))},6674:function(t,e,n){var r=n(2569),o=n(1540),i=n(2112),s=n(8250),c=r.Object,a=o("".split);t.exports=i((function(){return!c("z").propertyIsEnumerable(0)}))?function(t){return"String"==s(t)?a(t,""):c(t)}:c},5193:function(t,e,n){var r=n(1540),o=n(4354),i=n(7039),s=r(Function.toString);o(i.inspectSource)||(i.inspectSource=function(t){return s(t)}),t.exports=i.inspectSource},3500:function(t,e,n){var r,o,i,s=n(5965),c=n(2569),a=n(1540),u=n(1651),f=n(7632),l=n(5320),h=n(7039),p=n(9097),g=n(9012),d="Object already initialized",b=c.TypeError,v=c.WeakMap;if(s||h.state){var y=h.state||(h.state=new v),w=a(y.get),m=a(y.has),O=a(y.set);r=function(t,e){if(m(y,t))throw new b(d);return e.facade=t,O(y,t,e),e},o=function(t){return w(y,t)||{}},i=function(t){return m(y,t)}}else{var S=p("state");g[S]=!0,r=function(t,e){if(l(t,S))throw new b(d);return e.facade=t,f(t,S,e),e},o=function(t){return l(t,S)?t[S]:{}},i=function(t){return l(t,S)}}t.exports={set:r,get:o,has:i,enforce:function(t){return i(t)?o(t):r(t,{})},getterFor:function(t){return function(e){var n;if(!u(e)||(n=o(e)).type!==t)throw b("Incompatible receiver, "+t+" required");return n}}}},4354:function(t){t.exports=function(t){return"function"==typeof t}},8787:function(t,e,n){var r=n(2112),o=n(4354),i=/#|\.prototype\./,s=function(t,e){var n=a[c(t)];return n==f||n!=u&&(o(e)?r(e):!!e)},c=s.normalize=function(t){return String(t).replace(i,".").toLowerCase()},a=s.data={},u=s.NATIVE="N",f=s.POLYFILL="P";t.exports=s},1651:function(t,e,n){var r=n(4354);t.exports=function(t){return"object"==typeof t?null!==t:r(t)}},1274:function(t){t.exports=!1},8937:function(t,e,n){var r=n(2569),o=n(2430),i=n(4354),s=n(7652),c=n(7374),a=r.Object;t.exports=c?function(t){return"symbol"==typeof t}:function(t){var e=o("Symbol");return i(e)&&s(e.prototype,a(t))}},5756:function(t,e,n){"use strict";var r,o,i,s=n(2112),c=n(4354),a=n(9062),u=n(9299),f=n(8946),l=n(2931),h=n(1274),p=l("iterator"),g=!1;[].keys&&("next"in(i=[].keys())?(o=u(u(i)))!==Object.prototype&&(r=o):g=!0),null==r||s((function(){var t={};return r[p].call(t)!==t}))?r={}:h&&(r=a(r)),c(r[p])||f(r,p,(function(){return this})),t.exports={IteratorPrototype:r,BUGGY_SAFARI_ITERATORS:g}},9259:function(t){t.exports={}},6095:function(t,e,n){var r=n(9309);t.exports=function(t){return r(t.length)}},5598:function(t,e,n){var r=n(9318),o=n(2112);t.exports=!!Object.getOwnPropertySymbols&&!o((function(){var t=Symbol();return!String(t)||!(Object(t)instanceof Symbol)||!Symbol.sham&&r&&r<41}))},5965:function(t,e,n){var r=n(2569),o=n(4354),i=n(5193),s=r.WeakMap;t.exports=o(s)&&/native code/.test(i(s))},9062:function(t,e,n){var r,o=n(1421),i=n(3116),s=n(4328),c=n(9012),a=n(99),u=n(7934),f=n(9097),l=f("IE_PROTO"),h=function(){},p=function(t){return"' +
- '',
- {
- 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(
- '' +
- '' +
- '' +
- 'Initial content
' +
- '' +
- '',
- {
- 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)
- }
- }
- })
- })
-})
diff --git a/tests/js/cases/system/ui.select.test.js b/tests/js/cases/system/ui.select.test.js
deleted file mode 100644
index 17f854789e..0000000000
--- a/tests/js/cases/system/ui.select.test.js
+++ /dev/null
@@ -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(`
-
-
-
-
- `)
-
- 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 })
- })
- })
-})
diff --git a/tests/js/fixtures/framework/TestListener.js b/tests/js/fixtures/framework/TestListener.js
new file mode 100644
index 0000000000..5bd4f6bffb
--- /dev/null
+++ b/tests/js/fixtures/framework/TestListener.js
@@ -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);
diff --git a/tests/js/fixtures/framework/TestPlugin.js b/tests/js/fixtures/framework/TestPlugin.js
new file mode 100644
index 0000000000..568d6a6b93
--- /dev/null
+++ b/tests/js/fixtures/framework/TestPlugin.js
@@ -0,0 +1,11 @@
+/* globals window */
+
+((Snowboard) => {
+ class TestPlugin extends Snowboard.PluginBase {
+ testMethod() {
+ return 'Tested';
+ }
+ }
+
+ Snowboard.addPlugin('test', TestPlugin);
+})(window.Snowboard);
diff --git a/tests/js/fixtures/framework/TestPromiseListener.js b/tests/js/fixtures/framework/TestPromiseListener.js
new file mode 100644
index 0000000000..1126ed93ef
--- /dev/null
+++ b/tests/js/fixtures/framework/TestPromiseListener.js
@@ -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);
diff --git a/tests/js/fixtures/framework/TestSingleton.js b/tests/js/fixtures/framework/TestSingleton.js
new file mode 100644
index 0000000000..45989248bf
--- /dev/null
+++ b/tests/js/fixtures/framework/TestSingleton.js
@@ -0,0 +1,11 @@
+/* globals window */
+
+((Snowboard) => {
+ class TestSingleton extends Snowboard.Singleton {
+ testMethod() {
+ return 'Tested';
+ }
+ }
+
+ Snowboard.addPlugin('test', TestSingleton);
+})(window.Snowboard);
diff --git a/tests/js/helpers/FakeDom.js b/tests/js/helpers/FakeDom.js
new file mode 100644
index 0000000000..42d5302785
--- /dev/null
+++ b/tests/js/helpers/FakeDom.js
@@ -0,0 +1,156 @@
+/* globals __dirname, URL */
+
+import { JSDOM } from 'jsdom'
+import path from 'path'
+
+export default class FakeDom
+{
+ constructor(content, options)
+ {
+ if (options === undefined) {
+ options = {}
+ }
+
+ // 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 || 'Fake document'
+ this.bodyStart = options.bodyStart || ''
+ this.content = content || ''
+ this.bodyEnd = options.bodyEnd || ''
+ this.foot = options.foot || ''
+
+ // 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(``)
+ })
+ this.inline.forEach((script) => {
+ content.push(``)
+ })
+
+ // Add resolver
+ content.push(``)
+
+ // Add final content
+ content.push(this.bodyEnd)
+ content.push(this.foot)
+
+ return content.join('\n')
+ }
+}
diff --git a/tests/js/helpers/fakeDom.js b/tests/js/helpers/fakeDom.js
deleted file mode 100644
index 13757a91c3..0000000000
--- a/tests/js/helpers/fakeDom.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { JSDOM } from 'jsdom'
-
-const defaults = {
- url: 'https://winter.example.org/',
- referer: null,
- contentType: 'text/html',
- head: 'Fake document',
- bodyStart: '',
- bodyEnd: '',
- foot: '',
- 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
- }
- )
-
- return dom
-}
-
-export default fakeDom
diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js
new file mode 100644
index 0000000000..1363597d68
--- /dev/null
+++ b/tests/js/jest.config.js
@@ -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: [
+ // ""
+ // ],
+
+ // 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,
+};
diff --git a/tests/js/package.json b/tests/js/package.json
new file mode 100644
index 0000000000..650f38698f
--- /dev/null
+++ b/tests/js/package.json
@@ -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"
+ }
+}
diff --git a/themes/demo/layouts/default.htm b/themes/demo/layouts/default.htm
index 56dbaa99d4..0d3e3ff1ef 100644
--- a/themes/demo/layouts/default.htm
+++ b/themes/demo/layouts/default.htm
@@ -36,7 +36,7 @@
- {% framework extras %}
+ {% snowboard all %}
{% scripts %}