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 %} diff --git a/themes/demo/pages/ajax.htm b/themes/demo/pages/ajax.htm index 9d82ab8430..de7a4e9b16 100644 --- a/themes/demo/pages/ajax.htm +++ b/themes/demo/pages/ajax.htm @@ -5,9 +5,14 @@ Calculator
- - + - +