diff --git a/.gitattributes b/.gitattributes index feef00d..5fb509b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,17 +22,15 @@ *.gif binary *.ttf binary -# Ignore all test and documentation for archive +# Ignore some meta files when creating an archive of this repository +# We do not ignore any content, because this repo represents the +# `yiisoft/yii2-dev` package, which is expected to ship all tests and docs. /.github export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.scrutinizer.yml export-ignore /.travis.yml export-ignore -/phpunit.xml.dist export-ignore -/tests export-ignore -/docs export-ignore -/build export-ignore # Avoid merge conflicts in CHANGELOG # https://about.gitlab.com/2015/02/10/gitlab-reduced-merge-conflicts-by-90-percent-with-changelog-placeholders/ diff --git a/build/controllers/ReleaseController.php b/build/controllers/ReleaseController.php index cf2b8eb..ef49be8 100644 --- a/build/controllers/ReleaseController.php +++ b/build/controllers/ReleaseController.php @@ -212,13 +212,15 @@ class ReleaseController extends Controller $this->stdout("\n"); $this->stdout("Before you make a release briefly go over the changes and check if you spot obvious mistakes:\n\n", Console::BOLD); + $gitDir = reset($what) === 'framework' ? 'framework/' : ''; + $gitVersion = $versions[reset($what)]; if (strncmp('app-', reset($what), 4) !== 0) { - $this->stdout("- no accidentally added CHANGELOG lines for other versions than this one?\n"); + $this->stdout("- no accidentally added CHANGELOG lines for other versions than this one?\n\n git diff $gitVersion.. ${gitDir}CHANGELOG.md\n\n"); $this->stdout("- are all new `@since` tags for this relase version?\n"); } + $this->stdout("- other issues with code changes?\n\n git diff -w $gitVersion.. ${gitDir}\n\n"); $travisUrl = reset($what) === 'framework' ? '' : '-'.reset($what); $this->stdout("- are unit tests passing on travis? https://travis-ci.org/yiisoft/yii2$travisUrl/builds\n"); - $this->stdout("- other issues with code changes?\n"); $this->stdout("- also make sure the milestone on github is complete and no issues or PRs are left open.\n\n"); $this->printWhatUrls($what, $versions); $this->stdout("\n"); @@ -527,6 +529,12 @@ class ReleaseController extends Controller $this->stdout("\n\nThe following steps are left for you to do manually:\n\n"); $nextVersion2 = $this->getNextVersions($nextVersion, self::PATCH); // TODO support other versions $this->stdout("- wait for your changes to be propagated to the repo and create a tag $version on https://github.com/yiisoft/yii2-framework\n\n"); + $this->stdout(" git clone git@github.com:yiisoft/yii2-framework.git\n"); + $this->stdout(" cd yii2-framework/\n"); + $this->stdout(" export RELEASECOMMIT=$(git log --oneline |grep $version |grep -Po \"^[0-9a-f]+\")\n"); + $this->stdout(" git tag -s $version -m \"version $version\" \$RELEASECOMMIT\n"); + $this->stdout(" git tag --verify $version\n"); + $this->stdout(" git push --tags\n\n"); $this->stdout("- close the $version milestone on github and open new ones for {$nextVersion['framework']} and {$nextVersion2['framework']}: https://github.com/yiisoft/yii2/milestones\n"); $this->stdout("- create a release on github.\n"); $this->stdout("- release news and announcement.\n"); @@ -861,7 +869,13 @@ class ReleaseController extends Controller if ($state === 'changelog' && isset($lines[$l+1]) && strncmp($lines[$l+1], '---', 3) === 0) { $state = 'end'; } - ${$state}[] = $line; + // add continued lines to the last item to keep them together + if (!empty(${$state}) && trim($line !== '') && strpos($line, '- ') !== 0) { + end(${$state}); + ${$state}[key(${$state})] .= "\n" . $line; + } else { + ${$state}[] = $line; + } } return [$start, $changelog, $end]; } @@ -879,7 +893,7 @@ class ReleaseController extends Controller $i = 0; ArrayHelper::multisort($changelog, function($line) use (&$i) { - if (preg_match('/^- (Chg|Enh|Bug|New)( #\d+(, #\d+)*)?: .+$/', $line, $m)) { + if (preg_match('/^- (Chg|Enh|Bug|New)( #\d+(, #\d+)*)?: .+/', $line, $m)) { $o = ['Bug' => 'C', 'Enh' => 'D', 'Chg' => 'E', 'New' => 'F']; return $o[$m[1]] . ' ' . (!empty($m[2]) ? $m[2] : 'AAAA' . $i++); } diff --git a/composer.json b/composer.json index 5e66838..1dbc1f3 100644 --- a/composer.json +++ b/composer.json @@ -76,6 +76,7 @@ "ezyang/htmlpurifier": "~4.6", "cebe/markdown": "~1.0.0 | ~1.1.0", "bower-asset/jquery": "2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", + "bower-asset/jquery.inputmask": "~3.2.2 | ~3.3.3", "bower-asset/punycode": "1.3.*", "bower-asset/yii2-pjax": "~2.0.1" }, diff --git a/docs/guide-ja/README.md b/docs/guide-ja/README.md index b9af4c4..ad31716 100644 --- a/docs/guide-ja/README.md +++ b/docs/guide-ja/README.md @@ -178,14 +178,14 @@ RESTful ウェブサービス ウィジェット ------------ -* GridView: **未定** デモページへリンク -* ListView: **未定** デモページへリンク -* DetailView: **未定** デモページへリンク -* ActiveForm: **未定** デモページへリンク -* Pjax: **未定** デモページへリンク -* Menu: **未定** デモページへリンク -* LinkPager: **未定** デモページへリンク -* LinkSorter: **未定** デモページへリンク +* [GridView](http://www.yiiframework.com/doc-2.0/yii-grid-gridview.html) +* [ListView](http://www.yiiframework.com/doc-2.0/yii-widgets-listview.html) +* [DetailView](http://www.yiiframework.com/doc-2.0/yii-widgets-detailview.html) +* [ActiveForm](http://www.yiiframework.com/doc-2.0/guide-input-forms.html#activerecord-based-forms-activeform) +* [Pjax](http://www.yiiframework.com/doc-2.0/yii-widgets-pjax.html) +* [Menu](http://www.yiiframework.com/doc-2.0/yii-widgets-menu.html) +* [LinkPager](http://www.yiiframework.com/doc-2.0/yii-widgets-linkpager.html) +* [LinkSorter](http://www.yiiframework.com/doc-2.0/yii-widgets-linksorter.html) * [Bootstrap ウィジェット](https://github.com/yiisoft/yii2-bootstrap/blob/master/docs/guide-ja/README.md) * [jQuery UI ウィジェット](https://github.com/yiisoft/yii2-jui/blob/master/docs/guide-ja/README.md) diff --git a/docs/guide-ja/concept-components.md b/docs/guide-ja/concept-components.md index 16dae37..794a784 100644 --- a/docs/guide-ja/concept-components.md +++ b/docs/guide-ja/concept-components.md @@ -9,7 +9,7 @@ * [ビヘイビア](concept-behaviors.md) 個々にでも、組み合わせでも、これらの機能は Yii のクラスのカスタマイズ性と使いやすさをとても高めてくれます。たとえば、[[yii\jui\DatePicker|日付選択]] を行うユーザインターフェース·コンポーネントは、 -対話型の日付選択UIを生成するとき、[ビュー](structure-view.md) で次のように使用することができます: +対話型の日付選択UIを生成するとき、[ビュー](structure-views.md) で次のように使用することができます: ```php use yii\jui\DatePicker; diff --git a/docs/guide-ja/db-migrations.md b/docs/guide-ja/db-migrations.md index e8c2ccc..a347f0e 100644 --- a/docs/guide-ja/db-migrations.md +++ b/docs/guide-ja/db-migrations.md @@ -183,7 +183,7 @@ class m150101_185401_create_news_table extends Migration ### テーブルの作成 -```php +``` yii migrate/create create_post_table ``` @@ -217,7 +217,7 @@ class m150811_220037_create_post_table extends Migration テーブルのフィールドも直接に生成したい場合は、`--fields` オプションでフィールドを指定します。 -```php +``` yii migrate/create create_post_table --fields="title:string,body:text" ``` @@ -254,7 +254,7 @@ class m150811_220037_create_post_table extends Migration さらに多くのフィールド・パラメータを指定することも出来ます。 -```php +``` yii migrate/create create_post_table --fields="title:string(12):notNull:unique,body:text" ``` @@ -296,7 +296,7 @@ class m150811_220037_create_post_table extends Migration バージョン 2.0.8 からは、`foreignKey` キーワードを使って外部キーを生成することができます。 -```php +``` yii migrate/create create_post_table --fields="author_id:integer:notNull:foreignKey(user),category_id:integer:defaultValue(1):foreignKey,title:string,body:text" ``` @@ -417,7 +417,7 @@ class m160328_040430_create_post_table extends Migration ### テーブルを削除する -```php +``` yii migrate/create drop_post_table --fields="title:string(12):notNull:unique,body:text" ``` @@ -448,7 +448,7 @@ class m150811_220037_drop_post_table extends Migration カラムを追加するためには、次のようにします。 -```php +``` yii migrate/create add_position_column_to_post_table --fields="position:integer" ``` @@ -469,11 +469,17 @@ class m150811_220037_add_position_column_to_post_table extends Migration } ``` +次のようにして複数のカラムを指定することも出来ます。 + +``` +yii migrate/create add_xxx_column_yyy_column_to_zzz_table --fields="xxx:integer,yyy:text" +``` + ### カラムを削除する マイグレーションの名前が `drop_xxx_column_from_yyy_table` の形式である場合、ファイルの内容は、必要となる `addColumn` と `dropColumn` を含むことになります。 -```php +``` yii migrate/create drop_position_column_from_post_table --fields="position:integer" ``` @@ -498,7 +504,7 @@ class m150811_220037_drop_position_column_from_post_table extends Migration マイグレーションの名前が `create_junction_table_for_xxx_and_yyy_tables` の形式である場合は、中間テーブルを作成するのに必要となるコードが生成されます。 -```php +``` yii migrate/create create_junction_table_for_post_and_tag_tables --fields="created_at:dateTime" ``` @@ -869,11 +875,12 @@ return [ もう、`migrationTable` のコマンドラインオプションを使ってテーブルを指定する必要はなくなります。 -### Namespaced Migrations +### 名前空間を持つマイグレーション -Since 2.0.10 you can use namespaces for the migration classes. You can specify the list of the migration namespaces via -[[yii\console\controllers\MigrateController::migrationNamespaces|migrationNamespaces]]. Using of the namespaces for -migration classes allows you usage of the several source locations for the migrations. For example: +2.0.10 以降では、マイグレーションのクラスに名前空間を適用することが出来ます。 +マイグレーションの名前空間のリストをを [[yii\console\controllers\MigrateController::migrationNamespaces|migrationNamespaces]] によって指定することが出来ます。 +マイグレーションのクラスに名前空間を使うと、マイグレーションのソースについて、複数の配置場所を使用することが出来ます。 +例えば、 ```php return [ @@ -881,36 +888,35 @@ return [ 'migrate' => [ 'class' => 'yii\console\controllers\MigrateController', 'migrationNamespaces' => [ - 'app\migrations', // Common migrations for the whole application - 'module\migrations', // Migrations for the specific project's module -                'some\extension\migrations', // Migrations for the specific extension + 'app\migrations', // アプリケーション全体のための共通のマイグレーション + 'module\migrations', // プロジェクトの特定のモジュールのためのマイグレーション +                'some\extension\migrations', // 特定のエクステンションのためのマイグレーション ], ], ], ]; ``` -> Note: migrations applied from different namespaces will create a **single** migration history, e.g. you might be - unable to apply or revert migrations from particular namespace only. +> Note: 異なる名前空間に属するマイグレーションを適用しても、**単一の** マイグレーション履歴が生成されます。 +> つまり、特定の名前空間に属するマイグレーションだけを適用したり元に戻したりすることは出来ません。 -While operating namespaced migrations: creating new, reverting and so on, you should specify full namespace before -migration name. Note that backslash (`\`) symbol is usually considered a special character in the shell, so you need -to escape it properly to avoid shell errors or incorrect behavior. For example: +名前空間を持つマイグレーションを操作するときは、新規作成時も、元に戻すときも、マイグレーション名の前にフルパスの名前空間を指定しなければなりません。 +バックスラッシュ (`\`) のシンボルは、通常、シェルでは特殊文字として扱われますので、シェルのエラーや誤った動作を防止するために、適切にエスケープしなければならないことに注意して下さい。 +例えば、 ``` yii migrate/create 'app\\migrations\\createUserTable' ``` -> Note: migrations specified via [[yii\console\controllers\MigrateController::migrationPath|migrationPath]] can not - contain a namespace, namespaced migration can be applied only via [[yii\console\controllers\MigrateController::migrationNamespaces]] - property. +> Note: [[yii\console\controllers\MigrateController::migrationPath|migrationPath]] によって指定されたマイグレーションは、名前空間を持つことが出来ません。 + 名前空間を持つマイグレーションは [[yii\console\controllers\MigrateController::migrationNamespaces]] プロパティを通じてのみ適用可能です。 ### 分離されたマイグレーション -Sometimes using single migration history for all project migrations is not desirable. For example: you may install some -'blog' extension, which contains fully separated functionality and contain its own migrations, which should not affect -the ones dedicated to main project functionality. +プロジェクトのマイグレーション全体に単一のマイグレーション履歴を使用することが望ましくない場合もあります。 +例えば、完全に独立した機能性とそれ自身のためのマイグレーションを持つような 'blog' エクステンションをインストールする場合には、 +メインのプロジェクトの機能専用のマイグレーションに影響を与えたくないでしょう。 これらをお互いに完全に分離して適用かつ追跡したい場合は、別々の名前空間とマイグレーション履歴テーブルを使う 複数のマイグレーションコマンドを構成することが出来ます。 diff --git a/docs/guide-ja/intro-yii.md b/docs/guide-ja/intro-yii.md index 8322afc..b56c87f 100644 --- a/docs/guide-ja/intro-yii.md +++ b/docs/guide-ja/intro-yii.md @@ -46,7 +46,7 @@ Yii は現在、利用可能な二つのメジャーバージョン、すなわ 必要条件と前提条件 ------------------ -Yii 2.0 は PHP 5.4.0 以上を必要とします。 +Yii 2.0 は PHP 5.4.0 以上を必要とし、PHP 7 の最新バージョンで最高の力を発揮します。 個々の機能に対する詳細な必要条件は、全ての Yii リリースに含まれている必要条件チェッカを走らせることによって知ることが出来ます。 Yii を使うためには、オブジェクト指向プログラミング (OOP) の基本的な知識が必要です。 diff --git a/docs/guide-ja/output-client-scripts.md b/docs/guide-ja/output-client-scripts.md index 36c6fb0..aae33b2 100644 --- a/docs/guide-ja/output-client-scripts.md +++ b/docs/guide-ja/output-client-scripts.md @@ -1,98 +1,216 @@ クライアントスクリプトを扱う ============================ -> Note: この節はまだ執筆中です。 +今日のウェブアプリケーションでは、静的な HTML ページがレンダリングされてブラウザに送信されるだけでなく、 +JavaScript によって、既存の要素を操作したり、新しいコンテントを AJAX でロードしたりして、ブラウザに表示されるページを修正します。 +この節では、JavaScript と CSS をウェブサイトに追加したり、それらを動的に調整するために Yii によって提供されているメソッドを説明します。 -### スクリプトを登録する +## スクリプトを登録する -[[yii\web\View]] オブジェクトに対してスクリプトを登録することが出来ます。 +[[yii\web\View]] オブジェクトを扱う際には、フロントエンドスクリプトを動的に登録することが出来ます。 このための専用のメソッドが二つあります。 -すなわち、インラインスクリプトのための [[yii\web\View::registerJs()|registerJs()]] と、外部スクリプトのための [[yii\web\View::registerJsFile()|registerJsFile()]] です。 -インラインスクリプトは、設定のためや、動的に生成されるコードのために有用なものです。 -次のようにして、これらを追加するメソッドを使うことが出来ます。 + +- インラインスクリプトのための [[yii\web\View::registerJs()|registerJs()]] +- 外部スクリプトのための [[yii\web\View::registerJsFile()|registerJsFile()]] +### インラインスクリプトを登録する + +インラインスクリプトは、設定や、動的に生成されるコードのために有用なものです。 +また、[ウィジェット](structure-widgets.md) に含まれる再利用可能なフロントエンドコードによって生成されるコード断片もインラインスクリプトです。 +インラインスクリプトを追加するためのメソッド [[yii\web\View::registerJs()|registerJs()]] は、次のようにして使うことが出来ます。 ```php -$this->registerJs("var options = ".json_encode($options).";", View::POS_END, 'my-options'); +$this->registerJs( + "$('#myButton').on('click', function() { alert('ボタンがクリックされました'); });", + View::POS_READY, + 'my-button-handler' +); ``` -最初の引数は、ページに挿入したい実際の JS コードです。 -二番目の引数は、スクリプトがページのどの場所に挿入されるべきかを決定します。 +最初の引数は、ページに挿入したい実際の JS コードです。これが ` + + + + +
+ + + +
+ + + diff --git a/tests/js/tests/yii.test.js b/tests/js/tests/yii.test.js new file mode 100644 index 0000000..ce0ce7c --- /dev/null +++ b/tests/js/tests/yii.test.js @@ -0,0 +1,1425 @@ +var assert = require('chai').assert; +var sinon; +var withData = require('leche').withData; +var jsdom = require('mocha-jsdom'); + +var fs = require('fs'); +var vm = require('vm'); + +var StringUtils = { + /** + * Removes line breaks and redundant whitespaces from the given string. Used to compare HTML strings easier, + * regardless of the formatting. + * @param str Initial string to clean + * @returns {string} Cleaned string + */ + cleanHTML: function (str) { + return str.replace(/\r?\n|\r|\s\s+/g, ''); + } +}; + +describe('yii', function () { + var yiiPath = 'framework/assets/yii.js'; + var jQueryPath = 'vendor/bower/jquery/dist/jquery.js'; + var pjaxPath = 'vendor/bower/yii2-pjax/jquery.pjax.js'; + var sandbox; + var $; + var yii; + var yiiGetBaseCurrentUrlStub; + var yiiGetCurrentUrlStub; + + function registerPjax() { + var code = fs.readFileSync(pjaxPath); + var script = new vm.Script(code); + var sandbox = {jQuery: $, window: window, navigator: window.navigator}; + var context = new vm.createContext(sandbox); + script.runInContext(context); + } + + function registerTestableCode() { + registerPjax(); + + var code = fs.readFileSync(yiiPath); + var script = new vm.Script(code); + sandbox = {window: window, document: window.document, XMLHttpRequest: window.XMLHttpRequest}; + var context = new vm.createContext(sandbox); + + script.runInContext(context); + yii = sandbox.window.yii; + } + + /** + * Mapping of pjax data attributes with according plugin options + * @type {{}} + */ + var pjaxAttributes = { + 'data-pjax-push-state': 'push', + 'data-pjax-replace-state': 'replace', + 'data-pjax-scrollto': 'scrollTo', + 'data-pjax-push-redirect': 'pushRedirect', + 'data-pjax-replace-redirect': 'replaceRedirect', + 'data-pjax-skip-outer-containers': 'skipOuterContainers', + 'data-pjax-timeout': 'timeout' + }; + + /** + * Add pjax related attributes to all elements with "data-pjax" attribute. Used to prevent copy pasting and for + * better readability of the test HTML data. + */ + function addPjaxAttributes() { + $.each(pjaxAttributes, function (name, value) { + $('[data-pjax]').attr(name, value); + }); + } + + jsdom({ + html: fs.readFileSync('tests/js/data/yii.html', 'utf-8'), + src: fs.readFileSync(jQueryPath, 'utf-8') + }); + + before(function () { + $ = window.$; + registerTestableCode(); + sinon = require('sinon'); + addPjaxAttributes(); + yiiGetBaseCurrentUrlStub = sinon.stub(yii, 'getBaseCurrentUrl', function () { + return 'http://foo.bar'; + }); + yiiGetCurrentUrlStub = sinon.stub(yii, 'getCurrentUrl', function () { + return 'http://foo.bar/'; + }); + }); + + after(function () { + yiiGetBaseCurrentUrlStub.restore(); + yiiGetCurrentUrlStub.restore(); + }); + + describe('getCsrfParam method', function () { + it('should return current CSRF parameter name', function () { + assert.equal(yii.getCsrfParam(), '_csrf'); + }); + }); + + describe('getCsrfToken method', function () { + it('should return current CSRF parameter value', function () { + assert.equal(yii.getCsrfToken(), 'foobar'); + }); + }); + + describe('CSRF modifying methods', function () { + var initialCsrfParam; + var initialCsrfToken; + + beforeEach(function () { + initialCsrfParam = $('meta[name="csrf-param"]').attr('content'); + initialCsrfToken = $('meta[name="csrf-token"]').attr('content'); + }); + + // Restore CSRF parameter name and value to initial values because they are used in different tests + + afterEach(function () { + $('meta[name="csrf-param"]').attr('content', initialCsrfParam); + $('meta[name="csrf-token"]').attr('content', initialCsrfToken); + }); + + describe('setCsrfToken method', function () { + it('should update CSRF parameter name and value with new values', function () { + yii.setCsrfToken('_csrf1', 'foobar1'); + + assert.equal(yii.getCsrfParam(), '_csrf1'); + assert.equal(yii.getCsrfToken(), 'foobar1'); + }); + }); + + describe('refreshCsrfToken method', function () { + it('should assign CSRF token values for all forms during initialization', function () { + assert.equal($('#form1').find('input[name="_csrf"]').val(), 'foobar'); + assert.equal($('#form2').find('input[name="_csrf"]').val(), 'foobar'); + }); + + it('should update CSRF token values for all forms after modifying current CSRF token value', function () { + $('meta[name="csrf-token"]').attr('content', 'foobar1'); + yii.refreshCsrfToken(); + + assert.equal($('#form1').find('input[name="_csrf"]').val(), 'foobar1'); + assert.equal($('#form2').find('input[name="_csrf"]').val(), 'foobar1'); + }); + }); + }); + + describe('confirm method', function () { + var windowConfirmStub; + var confirmed; + var okSpy; + var cancelSpy; + + beforeEach(function () { + windowConfirmStub = sinon.stub(window, 'confirm', function () { + return confirmed; + }); + okSpy = sinon.spy(); + cancelSpy = sinon.spy(); + }); + + afterEach(function () { + windowConfirmStub.restore(); + okSpy.reset(); + cancelSpy.reset(); + }); + + withData({ + 'ok and cancel not set, "OK" selected': [{ + setOk: false, + setCancel: false, + confirmChoice: true, + expectOkCalled: false, + expectCancelCalled: false + }], + 'ok and cancel not set, "Cancel" selected': [{ + setOk: false, + setCancel: false, + confirmChoice: false, + expectOkCalled: false, + expectCancelCalled: false + }], + 'ok set, "OK" selected': [{ + setOk: true, + setCancel: false, + confirmChoice: true, + expectOkCalled: true, + expectCancelCalled: false + }], + 'ok set, "Cancel" selected': [{ + setOk: true, + setCancel: false, + confirmChoice: false, + expectOkCalled: false, + expectCancelCalled: false + }], + 'cancel set, "OK" selected': [{ + setOk: false, + setCancel: true, + confirmChoice: true, + expectOkCalled: false, + expectCancelCalled: false + }], + 'cancel set, "Cancel" selected': [{ + setOk: false, + setCancel: true, + confirmChoice: false, + expectOkCalled: false, + expectCancelCalled: true + }], + 'ok and cancel set, "OK" selected': [{ + setOk: true, + setCancel: true, + confirmChoice: true, + expectOkCalled: true, + expectCancelCalled: false + }], + 'ok and cancel set, "Cancel" selected': [{ + setOk: true, + setCancel: true, + confirmChoice: false, + expectOkCalled: false, + expectCancelCalled: true + }] + }, function (data) { + var setOk = data.setOk; + var setCancel = data.setCancel; + var confirmChoice = data.confirmChoice; + var expectOkCalled = data.expectOkCalled; + var expectCancelCalled = data.expectCancelCalled; + + var message = 'should return undefined, confirm should be called once with according message, '; + if (expectOkCalled && !expectCancelCalled) { + message += 'ok callback should be called once'; + } else if (!expectOkCalled && expectCancelCalled) { + message += 'cancel callback should be called once'; + } else if (!expectOkCalled && !expectCancelCalled) { + message += 'ok and cancel callbacks should not be called'; + } else { + message += 'ok and cancel callbacks should be called once'; + } + + it(message, function () { + confirmed = confirmChoice; + + var result = yii.confirm('Are you sure?', setOk ? okSpy : undefined, setCancel ? cancelSpy : undefined); + + assert.isUndefined(result); + assert.isTrue(windowConfirmStub.calledOnce); + assert.deepEqual(windowConfirmStub.getCall(0).args, ['Are you sure?']); + expectOkCalled ? assert.isTrue(okSpy.calledOnce) : assert.isFalse(okSpy.called); + expectCancelCalled ? assert.isTrue(cancelSpy.calledOnce) : assert.isFalse(cancelSpy.called); + }); + }); + }); + + describe('handleAction method', function () { + var windowLocationAssignStub; + var pjaxClickStub; + var pjaxSubmitStub; + var formSubmitsCount; + var initialFormsCount; + var $savedSubmittedForm; + + beforeEach(function () { + windowLocationAssignStub = sinon.stub(window.location, 'assign'); + pjaxClickStub = sinon.stub($.pjax, 'click'); + pjaxSubmitStub = sinon.stub($.pjax, 'submit'); + initialFormsCount = $('form').length; + countFormSubmits(); + }); + + afterEach(function () { + windowLocationAssignStub.restore(); + pjaxClickStub.restore(); + pjaxSubmitStub.restore(); + formSubmitsCount = undefined; + initialFormsCount = undefined; + $savedSubmittedForm = undefined; + $(document).off('submit'); + $('form').off('submit'); + }); + + function countFormSubmits() { + formSubmitsCount = 0; + $(document).on('submit', 'form', function () { + formSubmitsCount++; + $savedSubmittedForm = $(this).clone(); + + return false; + }); + } + + function verifyNoActions() { + assert.isFalse(windowLocationAssignStub.called); + assert.isFalse(pjaxClickStub.called); + + assert.equal(formSubmitsCount, 0); + assert.isFalse(pjaxSubmitStub.called); + assert.equal($('form').length, initialFormsCount); + } + + function verifyPageLoad(url) { + assert.isTrue(windowLocationAssignStub.calledOnce); + assert.deepEqual(windowLocationAssignStub.getCall(0).args, [url]); + assert.isFalse(pjaxClickStub.called); + + assert.equal(formSubmitsCount, 0); + assert.isFalse(pjaxSubmitStub.called); + assert.equal($('form').length, initialFormsCount); + } + + function verifyPageLoadWithPjax($element, event, pjaxContainerId) { + assert.isFalse(windowLocationAssignStub.called); + assert.isTrue(pjaxClickStub.calledOnce); + + assert.equal(formSubmitsCount, 0); + assert.isFalse(pjaxSubmitStub.called); + assert.equal($('form').length, initialFormsCount); + + assert.strictEqual(pjaxClickStub.getCall(0).args[0], event); + + var pjaxOptions = pjaxClickStub.getCall(0).args[1]; + + // container needs to be checked separately + + if (typeof pjaxOptions.container === 'string') { + assert.equal(pjaxOptions.container, '#' + pjaxContainerId || 'body'); + } else { + assert.instanceOf(pjaxOptions.container, $); + assert.equal(pjaxOptions.container.attr('id'), pjaxContainerId || 'body'); + } + delete pjaxOptions.container; + + assert.deepEqual(pjaxOptions, { + push: true, + replace: true, + scrollTo: 'scrollTo', + pushRedirect: 'pushRedirect', + replaceRedirect: 'replaceRedirect', + skipOuterContainers: 'skipOuterContainers', + timeout: 'timeout', + originalEvent: event, + originalTarget: $element + }); + } + + function verifyFormSubmit($form) { + assert.isFalse(windowLocationAssignStub.called); + assert.isFalse(pjaxClickStub.called); + + assert.equal(formSubmitsCount, 1); + assert.isFalse(pjaxSubmitStub.called); + assert.equal($('form').length, initialFormsCount); + + if ($form) { + assert.equal($form.attr('id'), $savedSubmittedForm.attr('id')); + } + } + + function verifyFormSubmitWithPjax($element, event, $form) { + assert.isFalse(windowLocationAssignStub.called); + assert.isFalse(pjaxClickStub.called); + + assert.equal(formSubmitsCount, 1); + assert.isTrue(pjaxSubmitStub.calledOnce); + assert.equal($('form').length, initialFormsCount); + + if ($form) { + assert.equal($form.attr('id'), $savedSubmittedForm.attr('id')); + } + + var pjaxEvent = pjaxSubmitStub.getCall(0).args[0]; + assert.instanceOf(pjaxEvent, $.Event); + assert.equal(pjaxEvent.type, 'submit'); + + var pjaxOptions = pjaxSubmitStub.getCall(0).args[1]; + + // container needs to be checked separately + + assert.instanceOf(pjaxOptions.container, $); + assert.equal(pjaxOptions.container.attr('id'), 'body'); + delete pjaxOptions.container; + + assert.deepEqual(pjaxOptions, { + push: true, + replace: true, + scrollTo: 'scrollTo', + pushRedirect: 'pushRedirect', + replaceRedirect: 'replaceRedirect', + skipOuterContainers: 'skipOuterContainers', + timeout: 'timeout', + originalEvent: event, + originalTarget: $element + }); + } + + describe('with no data-method', function () { + var noActionsMessage = 'should not do any actions related with page load and form submit'; + var pageLoadMessage = 'should load new page using the link from "href" attribute'; + var pageLoadWithPjaxMessage = pageLoadMessage + ' with pjax'; + + describe('with invalid elements or configuration', function () { + describe('with no form', function () { + withData({ + // Links + 'link, no href': ['.link-no-href'], + 'link, empty href': ['.link-empty-href'], + 'link, href contains anchor ("#") only': ['.link-anchor-href'], + 'link, no href, data-pjax': ['.link-no-href-pjax'], + 'link, empty href, data-pjax': ['.link-empty-href-pjax'], + 'link, href contains anchor ("#") only, data-pjax': ['.link-anchor-href-pjax'], + // Not links + 'not submit, no form': ['.not-submit-no-form'], + 'submit, no form': ['.submit-no-form'], + 'submit, data-form, form does not exist': ['.submit-form-not-exist'], + 'not submit, no form, data-pjax': ['.not-submit-no-form-pjax'], + 'submit, no form, data-pjax': ['.submit-no-form-pjax'], + 'submit, data-form, form does not exist, data-pjax': ['.submit-form-not-exist-pjax'] + }, function (elementSelector) { + it(noActionsMessage, function () { + var $element = $('.handle-action .no-method .invalid .no-form').find(elementSelector); + assert.lengthOf($element, 1); + + yii.handleAction($element); + verifyNoActions(); + }); + }); + }); + + describe('with form', function () { + withData({ + 'not submit, data-form': ['.not-submit-outside-form', '#not-submit-separate-form'], + 'not submit, inside a form': ['.not-submit-inside-form', '#not-submit-parent-form'], + 'not submit, data-form, data-pjax': [ + '.not-submit-outside-form-pjax', '#not-submit-separate-form' + ], + 'not submit, inside a form, data-pjax': [ + '.not-submit-inside-form-pjax', '#not-submit-parent-form-pjax' + ] + }, function (elementSelector, formSelector) { + it(noActionsMessage, function () { + var $element = $('.handle-action .no-method .invalid .form').find(elementSelector); + assert.lengthOf($element, 1); + + var $form = $(formSelector); + assert.lengthOf($form, 1); + + yii.handleAction($element); + verifyNoActions(); + }); + }); + }); + }); + + describe('with valid elements and configuration', function () { + describe('with no form', function () { + withData({ + 'link': ['.link'], + 'link, data-pjax="0"': ['.link-pjax-0'] + }, function (elementSelector) { + it(pageLoadMessage, function () { + var $element = $('.handle-action .no-method .valid').find(elementSelector); + assert.lengthOf($element, 1); + + yii.handleAction($element); + verifyPageLoad('/tests/index'); + }); + }); + + describe('with link, data-pjax and no pjax support', function () { + before(function () { + $.support.pjax = false; + }); + + after(function () { + $.support.pjax = true; + }); + + it(pageLoadMessage, function () { + var $element = $('.handle-action .no-method .valid .link-pjax'); + assert.lengthOf($element, 1); + + yii.handleAction($element); + verifyPageLoad('/tests/index'); + }); + }); + + withData({ + 'link, data-pjax': ['.link-pjax', 'body'], + 'link, data-pjax="1"': ['.link-pjax-1', 'body'], + 'link, data-pjax="true"': ['.link-pjax-true', 'body'], + 'link, data-pjax, outside a container': [ + '.link-pjax-outside-container', 'pjax-separate-container' + ], + 'link href, data-pjax, inside a container': ['.link-pjax-inside-container', 'pjax-container-2'] + }, function (elementSelector, expectedPjaxContainerId) { + it(pageLoadWithPjaxMessage, function () { + var event = $.Event('click'); + var $element = $('.handle-action .no-method .valid').find(elementSelector); + assert.lengthOf($element, 1); + + yii.handleAction($element, event); + verifyPageLoadWithPjax($element, event, expectedPjaxContainerId); + }); + }); + }); + + describe('with form', function () { + withData({ + 'submit, data-form': ['.submit-outside-form', '#submit-separate-form'], + 'submit, inside a form': ['.submit-inside-form', '#submit-parent-form'] + }, function (elementSelector, formSelector) { + it('should submit according existing form', function () { + var $element = $('.handle-action .no-method .valid').find(elementSelector); + assert.lengthOf($element, 1); + + var $form = $(formSelector); + var initialFormHtml = $form.get(0).outerHTML; + assert.lengthOf($form, 1); + + yii.handleAction($element); + + verifyFormSubmit($form); + assert.equal($savedSubmittedForm.get(0).outerHTML, initialFormHtml); + }); + }); + + withData({ + 'submit, data-form, data-pjax': ['.submit-outside-form-pjax', '#submit-separate-form'], + 'submit, inside a form, data-pjax': ['.submit-inside-form-pjax', '#submit-parent-form-pjax'] + }, function (elementSelector, formSelector) { + it('should submit according existing form with pjax', function () { + var event = $.Event('click'); + var $element = $('.handle-action .no-method .valid').find(elementSelector); + assert.lengthOf($element, 1); + + var $form = $(formSelector); + var initialFormHtml = $form.get(0).outerHTML; + assert.lengthOf($form, 1); + + yii.handleAction($element, event); + + verifyFormSubmitWithPjax($element, event, $form); + assert.equal($savedSubmittedForm.get(0).outerHTML, initialFormHtml); + }); + }); + }); + }); + }); + + describe('with data-method', function () { + describe('with no form', function () { + withData({ + 'invalid href': [ + '.bad-href', + '
' + ], + 'invalid data-params': [ + '.bad-params', + '
' + ], + 'data-method="get", data-params, target': [ + '.get-params-target', + '
' + + '' + + '' + + '
' + ], + 'data-method="head", data-params': [ + '.head', + '
' + + '' + + '' + + '' + + '' + + '
' + ], + 'data-method="post", data-params': [ + '.post', + '
' + + '' + + '' + + '' + + '
' + ], + 'data-method="post", data-params, upper case': [ + '.post-upper-case', + '
' + + '' + + '' + + '' + + '
' + ], + 'data-method="put", data-params': [ + '.put', + '
' + + '' + + '' + + '' + + '' + + '
' + ] + }, function (elementSelector, expectedFormHtml) { + it('should create temporary form and submit it', function () { + var $element = $('.handle-action .method .no-form').find(elementSelector); + assert.lengthOf($element, 1); + + yii.handleAction($element); + + verifyFormSubmit(); + assert.equal($savedSubmittedForm.get(0).outerHTML, expectedFormHtml); + }); + }); + + describe('with data-method="get", data-params, data-pjax', function () { + it('should create temporary form and submit it with pjax', function () { + var event = $.Event('click'); + var $element = $('.handle-action .method .no-form .get-params-pjax'); + assert.lengthOf($element, 1); + + yii.handleAction($element, event); + + verifyFormSubmitWithPjax($element, event); + + var expectedFormHtml = '
' + + '' + + '' + + '
'; + assert.equal($savedSubmittedForm.get(0).outerHTML, expectedFormHtml); + }); + }); + }); + + describe('with form', function () { + withData({ + 'data-form, new action, new method, data-params': [ + '.new-action-new-method', + '#method-form', + '
' + + '' + + '' + + '' + + '
' + ], + 'data-form, same action, same method, data-params': [ + '.same-action-same-method', + '#method-form', + '
' + + '' + + '' + + '' + + '
' + ], + 'data-form, invalid action, new method, data-params': [ + '.bad-action-new-method', + '#method-form', + '
' + + '' + + '' + + '' + + '
' + ], + // This is a test for this PR: + // https://github.com/yiisoft/yii2/pull/8014 + // + // However the bug currently can not be reproduced in jsdom: + // https://github.com/tmpvar/jsdom/issues/1688 + 'data-form, same action, same method, hidden "method" and "action" inputs in data-params': [ + '.hidden-method-action', + '#form-hidden-method-action', + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + ] + }, function (elementSelector, formSelector, expectedSubmittedFormHtml) { + var message = 'should modify according existing form, submit it and restore to initial condition'; + it(message, function () { + var $element = $('.handle-action .method .form').find(elementSelector); + assert.lengthOf($element, 1); + + var $form = $(formSelector); + var initialFormHtml = $form.get(0).outerHTML; + assert.lengthOf($form, 1); + + $form.data('yiiActiveForm', {}); + + yii.handleAction($element); + + verifyFormSubmit($form); + + var submittedFormHtml = StringUtils.cleanHTML($savedSubmittedForm.get(0).outerHTML); + assert.equal(submittedFormHtml, expectedSubmittedFormHtml); + assert.equal($form.get(0).outerHTML, initialFormHtml); + + // When activeForm is used for this form, the element triggered the submit should be remembered + // in jQuery data under according key + assert.strictEqual($form.data('yiiActiveForm').submitObject, $element); + }); + }); + + describe('with data-form, new action, new method, data-params, data-pjax', function () { + var message = 'should modify according existing form, submit it with pjax and restore to ' + + ' initial condition'; + it(message, function () { + var event = $.Event('click'); + var $element = $('.handle-action .method .form .new-action-new-method-pjax'); + assert.lengthOf($element, 1); + + var $form = $('#method-form'); + var initialFormHtml = $form.get(0).outerHTML; + assert.lengthOf($form, 1); + + yii.handleAction($element, event); + + verifyFormSubmitWithPjax($element, event, $form); + + var expectedSubmittedFormHtml = '
' + + '' + + '' + + '' + + '
'; + var submittedFormHtml = StringUtils.cleanHTML($savedSubmittedForm.get(0).outerHTML); + assert.equal(submittedFormHtml, expectedSubmittedFormHtml); + assert.equal($form.get(0).outerHTML, initialFormHtml); + }); + }); + }); + }); + }); + + describe('getQueryParams method', function () { + withData({ + 'no query parameters': ['/posts/index', {}], + 'query parameters': ['/posts/index?foo=1&bar=2', {foo: '1', bar: '2'}], + 'query parameter with multiple values (not array)': ['/posts/index?foo=1&foo=2', {'foo': ['1', '2']}], + 'query parameter with multiple values (array)': ['/posts/index?foo[]=1&foo[]=2', {'foo[]': ['1', '2']}], + 'anchor': ['/posts/index#post', {}], + 'query parameters, anchor': ['/posts/index?foo=1&bar=2#post', {foo: '1', bar: '2'}], + 'relative url, query parameters': ['?foo=1&bar=2', {foo: '1', bar: '2'}], + 'relative url, anchor': ['#post', {}], + 'relative url, query parameters, anchor': ['?foo=1&bar=2#post', {foo: '1', bar: '2'}], + 'skipped parameter name': ['?foo=1&=2&baz=3#post', {foo: '1', baz: '3'}], + 'skipped values': [ + '?foo=&PostSearch[tags][]=1&PostSearch[tags][]=', {foo: '', 'PostSearch[tags][]': ['1', '']} + ], + 'encoded URI component': ['/posts/index?query=' + encodeURIComponent('count >= 1'), {query: 'count >= 1'}], + // https://github.com/yiisoft/yii2/issues/11921 + 'encoded URI component, "+" signs': [ + '/posts/index?next+celebration+day=Sunday+January+1st&' + + 'increase+' + encodeURIComponent('++') + '+' + encodeURIComponent('%') + + '=' + + encodeURIComponent('++') + '+20+' + encodeURIComponent('%'), + {'next celebration day': 'Sunday January 1st', 'increase ++ %': '++ 20 %'} + ], + 'multiple arrays, anchor': [ + '/posts/index?CategorySearch[id]=1&CategorySearch[name]=a' + + '&PostSearch[name]=b&PostSearch[category_id]=2&PostSearch[tags][]=3&PostSearch[tags][]=4' + + '&foo[]=5&foo[]=6&bar=7#post', + { + 'CategorySearch[id]': '1', + 'CategorySearch[name]': 'a', + 'PostSearch[name]': 'b', + 'PostSearch[category_id]': '2', + 'PostSearch[tags][]': ['3', '4'], + 'foo[]': ['5', '6'], + bar: '7' + } + ] + }, function (url, expectedParams) { + it('should parse all query parameters from string and return them within a object', function () { + assert.deepEqual(yii.getQueryParams(url), expectedParams); + }); + }); + }); + + describe('initModule method', function () { + var calledInitMethods = []; + var rootModuleInit = function () { + calledInitMethods.push('rootModule'); + }; + + afterEach(function () { + calledInitMethods = []; + }); + + withData({ + 'isActive is undefined in the root module': [ + undefined, + rootModuleInit, + ['rootModule', 'isActiveUndefined', 'isActiveTrue', 'subModule', 'subModule2'] + ], + 'isActive is true in the root module': [ + true, + rootModuleInit, + ['rootModule', 'isActiveUndefined', 'isActiveTrue', 'subModule', 'subModule2'] + ], + 'isActive is false in the root module': [false, rootModuleInit, []], + 'isActive is undefined in the root module, init is not a method': [ + undefined, + 'init', + ['isActiveUndefined', 'isActiveTrue', 'subModule', 'subModule2'] + ] + }, function (rootModuleIsActive, rootModuleInit, expectedCalledInitMethods) { + var message = 'should call init method in the root module and all submodules depending depending on ' + + 'activity and if init is a valid method'; + it(message, function () { + // Root module + + var module = (function () { + return { + isActive: rootModuleIsActive, + init: rootModuleInit + }; + })(); + + // Submodules + + module.isActiveUndefined = (function () { + return { + init: function () { + calledInitMethods.push('isActiveUndefined'); + } + }; + })(); + + module.isActiveTrue = (function () { + return { + isActive: true, + init: function () { + calledInitMethods.push('isActiveTrue'); + } + }; + })(); + + module.isActiveFalse = (function () { + return { + isActive: false, + init: function () { + calledInitMethods.push('isActiveFalse'); + } + }; + })(); + + module.initNotFunction = (function () { + return { + init: 'init' + }; + })(); + + module.someInteger = 1; + module.someString = 'string'; + + module.subModule = (function () { + return { + init: function () { + calledInitMethods.push('subModule'); + } + }; + })(); + + module.subModule.subModule2 = (function () { + return { + init: function () { + calledInitMethods.push('subModule2'); + } + }; + })(); + + yii.initModule(module); + assert.deepEqual(calledInitMethods, expectedCalledInitMethods); + }); + }); + }); + + describe('CSRF handler', function () { + var server; + var yiiGetCsrfParamStub; + var fakeCsrfParam; + + beforeEach(function () { + server = sinon.fakeServer.create(); + window.XMLHttpRequest = global.XMLHttpRequest; + yiiGetCsrfParamStub = sinon.stub(yii, 'getCsrfParam', function () { + return fakeCsrfParam; + }) + }); + + afterEach(function () { + server.restore(); + yiiGetCsrfParamStub.restore(); + }); + + withData({ + 'crossDomain is false, csrfParam is not set': [false, undefined, undefined], + 'crossDomain is false, csrfParam is set': [false, 'foobar', 'foobar'], + 'crossDomain is true, csrfParam is not set': [true, undefined, undefined], + 'crossDomain is true, csrfParam is set': [true, 'foobar', undefined] + }, function (crossDomain, csrfParam, expectedHeaderValue) { + var message = 'should add header with CSRF token to AJAX requests only when crossDomain is false and ' + + 'csrf parameter is set'; + it(message, function () { + fakeCsrfParam = csrfParam; + $.ajax({ + url: '/tests/index', + crossDomain: crossDomain + }); + server.requests[0].respond(200, {}, ''); + + assert.lengthOf(server.requests, 1); + assert.equal(server.requests[0].requestHeaders['X-CSRF-Token'], expectedHeaderValue); + }); + }); + }); + + describe('redirect handler', function () { + var windowLocationAssignStub; + + beforeEach(function () { + windowLocationAssignStub = sinon.stub(window.location, 'assign'); + }); + + afterEach(function () { + windowLocationAssignStub.restore(); + }); + + // https://github.com/yiisoft/yii2/pull/10974 + describe('with xhr undefined', function () { + it('should not perform redirect', function () { + var e = $.Event('ajaxComplete'); + $('body').trigger(e); + + assert.isFalse(windowLocationAssignStub.called); + }); + }); + + describe('with xhr defined', function () { + var server; + var response = {result: 'OK'}; + + beforeEach(function () { + server = sinon.fakeServer.create(); + window.XMLHttpRequest = global.XMLHttpRequest; + }); + + afterEach(function () { + server.restore(); + }); + + describe('with custom header not set', function () { + it('should not perform redirect', function () { + $.get('/tests/index'); + server.requests[0].respond(200, {}, ''); + + assert.lengthOf(server.requests, 1); + assert.isFalse(windowLocationAssignStub.called); + }); + }); + + describe('with custom header set', function () { + it('should perform redirect', function () { + $.get('/tests/index'); + server.requests[0].respond(200, {'X-Redirect': 'http://redirect.yii'}, ''); + + assert.lengthOf(server.requests, 1); + assert.isTrue(windowLocationAssignStub.calledOnce); + assert.deepEqual(windowLocationAssignStub.getCall(0).args, ['http://redirect.yii']); + }); + }); + }); + }); + + describe('asset filters', function () { + var server; + var ajaxDataType; + var jsResponse = { + status: 200, + headers: {'Content-Type': 'application/x-custom-javascript'}, + body: 'var foobar = 1;' + }; + + before(function () { + // Sent ajax requests with dataType "script" and "jsonp" are not captured by Sinon's fake server. + // As a workaround we can use custom dataType. + // This $.ajaxPrefilter handler must be run after the one from yii.js. + ajaxDataType = 'customscript'; + $.ajaxPrefilter('script', function () { + return ajaxDataType; + }); + $.ajaxSetup({ + accepts: { + customscript: 'application/x-custom-javascript' + }, + converters: { + 'text customscript': function (result) { + return result; + } + } + }); + }); + + beforeEach(function () { + server = sinon.fakeServer.create(); + // Allowed: /js/test.js, http://foo.bar/js/test.js + server.respondWith(/(http:\/\/foo\.bar)?\/js\/.+\.js/, [ + jsResponse.status, + jsResponse.headers, + jsResponse.body + ]); + window.XMLHttpRequest = global.XMLHttpRequest; + }); + + after(function () { + ajaxDataType = 'script'; + }); + + afterEach(function () { + server.restore(); + }); + + function respondToRequestWithSuccess(requestIndex) { + server.requests[requestIndex].respond(jsResponse.status, jsResponse.headers, jsResponse.body); + } + + function respondToRequestWithError(requestIndex) { + server.requests[requestIndex].respond(404, {}, ''); + } + + // Note: Please do not test loading of the script with the same name in different tests. After successful + // loading it will stay in loadedScripts and the load will be aborted unless this script is reloadable. + + describe('with scripts', function () { + var XHR_UNSENT; + var XHR_OPENED; + var XHR_DONE; + + before(function () { + XHR_UNSENT = window.XMLHttpRequest.UNSENT; + XHR_OPENED = window.XMLHttpRequest.OPENED; + XHR_DONE = window.XMLHttpRequest.DONE; + }); + + describe('with jsonp dataType', function () { + it('should load it as many times as it was requested', function () { + $.ajax({ + url: '/js/jsonp.js', + dataType: 'jsonp' + }); + server.respond(); + + $.ajax({ + url: '/js/jsonp.js', + dataType: 'jsonp' + }); + server.respond(); + + assert.lengthOf(server.requests, 2); + assert.equal(server.requests[0].readyState, XHR_DONE); + assert.equal(server.requests[1].readyState, XHR_DONE); + }); + }); + + describe('with scripts loaded on the page load', function () { + it('should prevent of loading them again for both relative and absolute urls', function () { + $.getScript('/js/existing1.js'); + server.respond(); + + $.getScript('http://foo.bar/js/existing1.js'); + server.respond(); + + $.getScript('/js/existing2.js'); + server.respond(); + + $.getScript('http://foo.bar/js/existing2.js'); + server.respond(); + + assert.lengthOf(server.requests, 0); + }); + }); + + describe('with script not loaded before', function () { + it('should load it only once for both relative and absolute urls', function () { + $.getScript('/js/new.js'); + server.respond(); + + $.getScript('/js/new.js'); + server.respond(); + + $.getScript('http://foo.bar/js/new.js'); + server.respond(); + + assert.lengthOf(server.requests, 1); + assert.equal(server.requests[0].readyState, XHR_DONE); + }); + }); + + describe('with reloadableScripts set', function () { + before(function () { + yii.reloadableScripts = [ + '/js/reloadable.js', + // https://github.com/yiisoft/yii2/issues/11494 + '/js/reloadable/script*.js' + ]; + }); + + after(function () { + yii.reloadableScripts = []; + }); + + describe('with match', function () { + withData({ + 'relative url, exact': ['/js/reloadable.js'], + 'relative url, wildcard': ['http://foo.bar/js/reloadable/script1.js'], + 'absolute url, exact': ['http://foo.bar/js/reloadable.js'], + 'absolute url, wildcard': ['http://foo.bar/js/reloadable/script2.js'] + }, function (url) { + it('should load it as many times as it was requested', function () { + $.getScript(url); + server.respond(); + + $.getScript(url); + server.respond(); + + assert.lengthOf(server.requests, 2); + assert.equal(server.requests[0].readyState, XHR_DONE); + assert.equal(server.requests[1].readyState, XHR_DONE); + }); + }); + }); + + describe('with no match', function () { + withData({ + 'relative url': ['/js/not_reloadable.js'], + 'absolute url': ['http://foo.bar/js/reloadable/not_reloadable_script.js'] + }, function (url) { + it('should load it only once for both relative and absolute urls', function () { + $.getScript(url); + server.respond(); + + $.getScript(url); + server.respond(); + + assert.lengthOf(server.requests, 1); + assert.equal(server.requests[0].readyState, XHR_DONE); + }); + }); + }); + + describe('with failed load after successful load and making it not reloadable', function () { + it('should allow to load it again', function () { + $.getScript('/js/reloadable/script_fail.js'); + respondToRequestWithSuccess(0); + + $.getScript('/js/reloadable/script_fail.js'); + respondToRequestWithError(1); + yii.reloadableScripts = []; + + $.getScript('/js/reloadable/script_fail.js'); + respondToRequestWithError(2); + + assert.lengthOf(server.requests, 3); + assert.equal(server.requests[0].readyState, XHR_DONE); + assert.equal(server.requests[1].readyState, XHR_DONE); + assert.equal(server.requests[2].readyState, XHR_DONE); + }); + }); + }); + + // https://github.com/yiisoft/yii2/issues/10358 + // https://github.com/yiisoft/yii2/issues/13307 + + describe('with concurrent requests', function () { + // Note: it's not possible to imitate successful loading of all requests, because after the first one + // loads, the rest will be aborted by yii.js (readyState will be 0 (UNSENT)). + // Sinon requires request to have state 1 (OPENED) for the response to be sent. + // Anyway one of the requests will be loaded at least a bit earlier than the others, so we can test + // that. + describe('with one successfully completed after one failed', function () { + it('should abort remaining requests and disallow to load the script again', function () { + $.getScript('/js/concurrent_success.js'); + $.getScript('/js/concurrent_success.js'); + $.getScript('/js/concurrent_success.js'); + + assert.lengthOf(server.requests, 3); + + assert.equal(server.requests[0].readyState, XHR_OPENED); + assert.equal(server.requests[1].readyState, XHR_OPENED); + assert.equal(server.requests[2].readyState, XHR_OPENED); + + respondToRequestWithError(2); + respondToRequestWithSuccess(1); + + assert.equal(server.requests[0].readyState, XHR_UNSENT); + assert.isTrue(server.requests[0].aborted); + assert.equal(server.requests[1].readyState, XHR_DONE); + assert.isUndefined(server.requests[1].aborted); + assert.equal(server.requests[2].readyState, XHR_DONE); + assert.isUndefined(server.requests[2].aborted); + + $.getScript('/js/concurrent_success.js'); + server.respond(); + + assert.lengthOf(server.requests, 3); + }); + }); + + describe('with all requests failed', function () { + it('should allow to load the script again', function () { + $.getScript('/js/concurrent_fail.js'); + $.getScript('/js/concurrent_fail.js'); + $.getScript('/js/concurrent_fail.js'); + + respondToRequestWithError(0); + respondToRequestWithError(1); + respondToRequestWithError(2); + + $.getScript('/js/concurrent_fail.js'); + + respondToRequestWithSuccess(3); + + assert.lengthOf(server.requests, 4); + assert.equal(server.requests[0].readyState, XHR_DONE); + assert.equal(server.requests[1].readyState, XHR_DONE); + assert.equal(server.requests[2].readyState, XHR_DONE); + assert.equal(server.requests[3].readyState, XHR_DONE); + }); + }); + + describe('with requests to different urls successfully completed', function () { + it('should not cause any conflicts and disallow to load these scripts again', function () { + $.getScript('/js/concurrent_url1.js'); + $.getScript('/js/concurrent_url2.js'); + + $.getScript('/js/concurrent_url1.js'); + $.getScript('/js/concurrent_url2.js'); + + respondToRequestWithSuccess(0); + respondToRequestWithSuccess(3); + + $.getScript('/js/concurrent_url1.js'); + $.getScript('/js/concurrent_url2.js'); + + assert.lengthOf(server.requests, 4); + assert.equal(server.requests[0].readyState, XHR_DONE); + assert.isUndefined(server.requests[0].aborted); + assert.equal(server.requests[1].readyState, XHR_UNSENT); + assert.isTrue(server.requests[1].aborted); + assert.equal(server.requests[2].readyState, XHR_UNSENT); + assert.isTrue(server.requests[2].aborted); + assert.equal(server.requests[3].readyState, XHR_DONE); + assert.isUndefined(server.requests[3].aborted); + }); + }); + }); + }); + + describe('with stylesheets', function () { + // Note: All added stylesheets for the tests must have ".added-stylesheet" class for the proper cleanup + + afterEach(function () { + $('.added-stylesheet').remove(); + }); + + describe('with not reloadable assets', function () { + it('should not allow to add duplicate stylesheets for both relative and absolute urls', function () { + var $styleSheets = $('.asset-filters .stylesheets'); + assert.lengthOf($styleSheets, 1); + + $.get('/tests/index', function () { + $styleSheets.append( + '' + ); + $styleSheets.append( + '' + ); + }); + + server.requests[0].respond(200, {}, ''); + + assert.lengthOf($('link[rel="stylesheet"]'), 2); + assert.lengthOf($('#stylesheet1'), 1); + assert.lengthOf($('#stylesheet2'), 1); + }); + }); + + describe('with reloadable assets', function () { + before(function () { + yii.reloadableScripts = [ + '/css/reloadable.css', + // https://github.com/yiisoft/yii2/issues/11494 + '/css/reloadable/stylesheet*.css' + ]; + }); + + after(function () { + yii.reloadableScripts = []; + }); + + it('should allow to add duplicate stylesheets for both relative and absolute urls', function () { + var $styleSheets = $('.asset-filters .stylesheets'); + assert.lengthOf($styleSheets, 1); + + $.get('/tests/index', function () { + $styleSheets.append( + '' + ); + $styleSheets.append( + '' + ); + $styleSheets.append( + '' + ); + $styleSheets.append( + '' + ); + }); + + server.requests[0].respond(200, {}, ''); + + assert.lengthOf($('link[rel=stylesheet]'), 6); + }); + }); + }); + }); + + describe('data methods', function () { + var windowConfirmStub; + var yiiConfirmSpy; + var yiiHandleActionStub; + var extraEventHandlerSpy; + + beforeEach(function () { + windowConfirmStub = sinon.stub(window, 'confirm', function () { + return true; + }); + yiiConfirmSpy = sinon.spy(yii, 'confirm'); + yiiHandleActionStub = sinon.stub(yii, 'handleAction'); + extraEventHandlerSpy = sinon.spy(); + $(window.document).on('click change', '.data-methods-element', extraEventHandlerSpy); + }); + + afterEach(function () { + windowConfirmStub.restore(); + yiiConfirmSpy.restore(); + yiiHandleActionStub.restore(); + extraEventHandlerSpy.reset(); + $(window.document).off('click change', '.data-methods-element'); + }); + + describe('with data not set', function () { + it('should continue handling interaction with element', function () { + var event = $.Event('click'); + var $element = $('#data-methods-no-data'); + assert.lengthOf($element, 1); + + $element.trigger(event); + + assert.isFalse(yiiConfirmSpy.called); + assert.isFalse(yiiHandleActionStub.called); + assert.isTrue(extraEventHandlerSpy.calledOnce); + }); + }); + + describe('with clickableSelector with data-confirm', function () { + it('should call confirm and handleAction methods', function () { + var event = $.Event('click'); + var elementId = 'data-methods-click-confirm'; + var $element = $('#' + elementId); + assert.lengthOf($element, 1); + + $element.trigger(event); + + assert.isTrue(yiiConfirmSpy.calledOnce); + assert.equal(yiiConfirmSpy.getCall(0).args[0], 'Are you sure?'); + assert.isFunction(yiiConfirmSpy.getCall(0).args[1]); + // https://github.com/yiisoft/yii2/issues/10097 + assert.instanceOf(yiiConfirmSpy.getCall(0).thisValue, window.HTMLAnchorElement); + assert.equal(yiiConfirmSpy.getCall(0).thisValue.id, elementId); + + assert.isTrue(yiiHandleActionStub.calledOnce); + assert.equal(yiiHandleActionStub.getCall(0).args[0].attr('id'), elementId); + assert.strictEqual(yiiHandleActionStub.getCall(0).args[1], event); + + assert.isFalse(extraEventHandlerSpy.called); + }); + }); + + describe('with changeableSelector without data-confirm', function () { + var elementId = 'data-methods-change'; + var $element; + + before(function () { + $element = $('#' + elementId); + }); + + after(function () { + $element.val(''); + }); + + it('should call handleAction method only', function () { + var event = $.Event('change'); + assert.lengthOf($element, 1); + + $element.val(1); + $element.trigger(event); + + assert.isFalse(yiiConfirmSpy.called); + + assert.isTrue(yiiHandleActionStub.calledOnce); + assert.equal(yiiHandleActionStub.getCall(0).args[0].attr('id'), elementId); + assert.strictEqual(yiiHandleActionStub.getCall(0).args[1], event); + + assert.isFalse(extraEventHandlerSpy.called); + }); + }); + }); +});